TLDR: One route downloads a doc as docx, PDF or HTML, a sheet as xlsx, PDF or HTML, a deck as pptx, PDF or HTML and a drawing as PDF (
apps/api/src/lib/export/); a drawing's SVG is the editor's own, drawn by the browser. Import turns an xlsx into a sheet, a docx into a doc and a pptx into slides (lib/import/). Every format but xlsx, docx and pptx is one HTML document, and PDF is that document through WeasyPrint. xlsx, docx and pptx each have a writer of their own, and the docx writer takes its look from the same stylesheet the editor draws with. An import writes nothing until the transform succeeds.
A user exports from the file menu of the docs, sheets, slides and drawing editors, or from a Drive item's menu. Import… is a row of the same file menu in docs, sheets and slides. After the user picks a file, one dialog (DocumentImportPicker) says how it comes in before anything is written: a deck inserts the file's slides after the current one or replaces all its slides, a workbook inserts the file's sheets as new tabs or replaces the spreadsheet, and a doc is only replaced, with a warning. Every replace saves a version first (§ A replace saves a version first). Convert to Sheet, Convert to Document and Convert to Slides are file actions on an xlsx, docx or pptx, and make a new document from it. Which extension converts to which type is one table, CONVERT_SOURCES (packages/lib/src/types/drive.ts), that the file action, the route and the import picker's Drive rows all read, by the file's extension: Drive stores the MIME an upload claimed, and an Office file often arrives as application/octet-stream. Every document that exports is a collab document, so its content is the Yjs state stored in its container's data.db (COLLAB.md), and an export renders that state. The heavy work runs in a one-shot transform process (DOCUMENT-TRANSFORMS.md).
The editor, quick look, the HTML download and the PDF draw a doc from one stylesheet, eigen-prose.css, so they lay it out block for block alike. The docx writer reads its sizes, spacing and colors from that stylesheet too. The imports and the Office writers aim for parity with Word, Excel and Google's apps: a property a document's schema can't hold is a candidate to add to the schema, never something to flatten for good. The rest of this doc covers how each format is built and kept safe. Three things surprise people: WeasyPrint opens only data: URIs, through a fetcher of Eigen's own (§ WeasyPrint fetches only data: URIs); every body keeps only data: references, so an HTML download fetches nothing either (§ The sanitizer); and an import checks write access again as its last step (§ An import writes nothing).
EIGEN_DOC_TYPE_INFO[type].exportFormats (packages/lib/src/types/drive.ts) lists what a type offers, in menu order. The file menu and the drive item menu read it through exportFormatsFor. The route checks the same list, and offers() in export-document.ts narrows each entry to a literal. So a format added to a type without a transform envelope fails to compile instead of reaching a user as a 400.
Export and import both dispatch on the container type, not the mime type. A mime type is caller-controlled on upload, so a plain file wearing an Eigen mime is a 400 rather than a transform over data it does not have. The export route resolves the file through SharedDrive, so the ACL check is the drive's; export is not a Drive method.
runDocumentExport is the one main-thread entry. The runner admits the job before anything is read. Once the transform process has read the document, it names the media files the document holds, and collectExportMedia (export/media.ts) prepares those and no others, one at a time: for the HTML download the viewer's cached WebP preview, for the PDF and the Office formats a JPEG or PNG made from each raster's source (§ A JPEG or PNG from the source), and for any format an SVG's own bytes. That is Mount I/O plus the capped thumbnail path, so it stays on the main thread. What the export sends past 200 MB in all makes it a 413, before any read when the named SVGs alone pass it (DOCUMENT-TRANSFORMS.md). The prep runs inside the export's 120 s transform deadline, so an export is one deadline end to end: once it is spent, or the client is gone, the prep reads no more media and the job fails as a timed-out or canceled one does.
The one-shot transform process (DOCUMENT-TRANSFORMS.md) materializes the captured Yjs blobs, then renders and sanitizes the HTML, or writes the docx, pptx or xlsx. The docx, pptx and xlsx writers load lazily, so an HTML export evaluates none of them. A blob that fails to decode is skipped with a corrupt-blobs-skipped warning, as on a live read. WeasyPrint stays outside the runner, since it is already a separate process, but it takes the transform child's treatment: one render at a time (htmlToPdf, export/weasyprint.ts), because a render can hold gigabytes as a child can, under the child's Linux limits (limitedCommand, DOCUMENT-TRANSFORMS.md), and killed by the request's signal when the client goes, or dropped from the queue if it goes while the render waits its turn. Four renders may wait, about the foreground exports the runner admits behind a running one; past that a PDF export answers the runner's 503.
| Outcome | Status |
|---|---|
| WeasyPrint not installed, older than 68 or its import past 5 s (PDF only) | 501 naming the minimum and how to install it |
| WeasyPrint past its 60 s timeout, or exits non-zero | 504, or a bare 500 with its traceback in the log. A SIGSEGV during the render renders once more in what is left of the 60 s, unless the first took over half |
| The prepared media past 200 MB in all, the named SVGs' declared sizes, or one Office source | 413 |
| Transform runner saturated, or four PDF renders already waiting | 503, never a main-thread fallback |
| Empty deck or empty drawing | 400 |
WeasyPrint's Python died of a SIGSEGV now and then as it exited, after it had written all its output: 5 of 1,010 runs of a test's WeasyPrint script on a loaded Mac, each in a glib thread that ran code from a library the exit had already unloaded. htmlToPdf read that as a failed render and rendered again, which doubled the export, and failed it with a 500 when the first render took over 30 s. So weasyprint-render.py flushes the PDF and leaves with os._exit, before Python's exit. That skips WeasyPrint's finalizer, which removes the folder it writes a document's @font-face fonts into, so the script points every temporary file at a folder of its own and removes it after the PDF is written. A SIGSEGV that still comes is one during the render, which renders once more.
The response is silent while the job queues and its transform runs. Queue wait plus the 120 s transform deadline plus the wait for WeasyPrint and its 60 s can outlast any server-wide idleTimeout, which Bun caps at 255 s. So the export, import and convert routes call server.timeout(request, 0). Without it Bun closes the silent connection, which aborts the signal and kills the job mid-transform. request.signal still fires on a real disconnect, and the runner then drops the queued job or kills its child process, as htmlToPdf kills its render.
html and pdf-html render one document from one renderer, so WeasyPrint prints what the HTML download serves, but for what differs on paper. There is one document per type to get right, not one per format. Each type's entry (renderEigendocExport, renderEigensheetsExport, renderEigenslidesExport) decides once which one it renders: a sheet's page fits its widest and tallest sheet, a deck's pages leave the fit box, and a doc fakes small caps where the font has none (DOCS.md) and draws its equations as SVG (§ The PDF draws an equation as the SVG MathJax makes of it). A doc's PDF also places its bands as running elements before the body, with the stylesheet that prints them in the margins and numbers the pages, where the download shows page 1's header above the body and the footer below it, a page number as its # (§ The PDF prints the bands). A doc's <title> keeps the extension (Report.eigendoc) while the docx title drops it; that output is pinned in apps/api/src/test/export/document-export-route.test.ts.
A doc's page is the one it stores (DOCS.md), read beside the body by readEigendocPage: the HTML's screen page and the PDF's @page from pageStylesheet (in printExtras, export/doc/transform.ts), and the docx's page size, margins and text column from pageTwips. A margin change rewrites no figure, so a figure wider than the column writes at the column's width, as the editor draws it at max-width: 100%, and a table wider than the column prints fitted to it (§ A table wider than the text column prints fitted to it). The doc's Normal is read beside them by readEigendocStyle: the custom properties on .page, the font the PDF's small caps are faked by, and the docx's defaults.
A doc's header and footer (DOCS.md) reach every export through readEigendocBands (document/doc.ts): a band with content only, page 1's only when the page differs, and without the nodes a band's schema leaves out, which only a forged peer writes. Each renders through renderDocHtml, as the body does, and its media resolves as the body's. The PDF prints a band up to MAX_BAND_BLOCKS (200 blocks, counted inside tables and lists) and MAX_BAND_TEXT (20,000 characters), the block that passes them cut inside to what fits, so a band keeps its first lines, with a bands-cut warning on the job's log line that counts each band cut once (printedBands, export/doc/transform.ts). WeasyPrint lays a running element out again on every page, so a 2,000-paragraph header took a 400-paragraph PDF 22.6 s and a 4,000-paragraph one ran past the 60 s timeout; cut, the 4,000 take 4.3 s. Half an A4 page holds about 60 lines of 6 pt text, and a band taller than half the page prints over the body anyway (§ The PDF prints the bands), so the cut leaves what prints clear of the body. The docx and the HTML download keep every band whole.
The document is self-contained, because WeasyPrint and a downloaded file have no app to fetch from. One builder writes it for every type, exportHtmlDocument (export/html-document.ts): the doctype, the policy <meta>, the title and the fonts (§ An export embeds only the font slices its text draws), around the stylesheet and body each type renders. Only the canvas document asks for the viewport <meta>, which the deck's fit box needs to scale a page to a phone. A doc embeds eigen-prose.css, flattened once at load by document/prose-css.ts: WeasyPrint does not read CSS nesting, so nesting is expanded, .dark rules are dropped and theme variables become values. The only var() left is a doc's Normal (--doc-*), with Eigen's look as its fallback, which .page sets for a doc whose Normal is its own, its header and footer inside it (DOCS.md). The font weights come from font-weights.css, rounded to the nearest multiple of 100 because WeasyPrint accepts no other weight: Heading 1 to 4 and table headers print at 500, Heading 5 and 6 at the body's 400, and bold at 600.
The doc node renderers (export/doc/render.ts) are pure and shared with the preview: renderDocHtml renders a body for both, with one node mapping, so quick look draws a list's start and type as the export does. They highlight with the backend's one lowlight (document/lowlight.ts), which the docx writer and reader share. The doc schema is built on it, so the API process loads every grammar at startup. A figure resolves its media name to a data: URI and, with none, a pasted base64 data: src to the one the transform process re-encodes from it, without its EXIF (§ A JPEG or PNG from the source); any other data: src but an SVG draws nothing, and any other src is kept; a missing image renders no <img>, and an external src is stripped by the sanitizer. So a figure with only an external src exports empty, with no warning, an open ROADMAP row. A task item is rendered by hand, because the static renderer drops checked. A comment mark renders as its text alone: comments are not exported, so no format and no preview draws a highlight. Code in no language lowlight knows goes through highlightAuto, which tries every grammar it has, so each render spends one budget of 64 KB on it (codeHighlighter): from the code block that passes it on, such a block is plain text in the HTML, the PDF, the docx and a code file's preview. A peer's 100 blocks of 63 KB would otherwise take minutes of every export. The docx spends one budget on its body and bands; the HTML and the PDF render the body and each band apart, so each has its own. The doc preview counts a code block nested in a list item against its 20 blocks, as it counts a top-level one.
Every bundled face is cut into slices, WOFF2 files of one family that each draw a range of characters (TYPOGRAPHY.md). An export embeds a slice where its family is one the document can draw in and its range meets a character the document can draw, each base64'd into an @font-face rule with its unicode-range (slicesFaceCss, export/fonts.ts). slicesFor (packages/lib/src/constants/font-slices.ts) picks them, the function the browser's image download picks by too. WeasyPrint reads every embedded byte on every PDF, and whole faces made a golden export 1.0 to 1.16 MB. With slices the golden doc's HTML is about 379 KB, its sheet's about 306 KB and its deck's about 311 KB.
The families are the ones its stylesheet draws in, which each type passes as cssFamilies, every bundled family a font-family value in the sanitized body names (namedFamilies), such as a font mark's style, a cell's class, a text box's style or a chart's SVG, every emoji and CJK fallback, and the math face where the body holds a <math>. Only a whole name in a font-family value counts: a paragraph that says Inter embeds nothing, and Interstate is not Inter. A doc's stylesheet draws in its Normal's family, as eigen-prose.css names no other bundled family as its fallback, and in the monospace where code renders (renderEigendocExport, export/doc/transform.ts); a sheet's table and a canvas page draw in Inter.
The characters are the sanitized body's, each letter's other case for CSS caps, and printable ASCII and the list disc, which cover what the export's CSS draws itself: list markers, roman numerals, a contents entry's leader, and page and footnote numbers (drawnCodePoints). So any document embeds its families' Latin slices, upright and italic, and a Latin-only one no emoji or CJK slice. One equation embeds the whole math face, about 0.9 MB of base64, in the HTML download alone, as the PDF draws equations as paths.
The server reads a slice where the UI package keeps it, through the package's ./assets/fonts/* export (Bun.resolveSync), since the API runs from the repository, and keeps each slice's rule once built, because an SVG preview asks for its slices on every request. Apart from the math face, which is one slice, the docx's static TTFs are the only whole faces an export reads.
A page-less doc draws a table past its text column on screen (DOCS.md), but its PDF stays paged on the stored paper, as Google's does, with its bands, page numbers and notes at the page's foot. Nothing else in the render reads the mode, so a page-less doc's PDF is its paged twin's. A table at its stored widths would run off the paper, so withTablesFitted (export/doc/transform.ts) fits every table whose first row's widths sum past the column, each cell's widths by the one factor of its table, as Word's autofit shrinks a table into the page, through columnFit (packages/lib/src/docs/eigendoc/table.ts), taken once per table. A column without a width keeps none. The stored widths stay as they are: the fit is the export's alone. The text keeps its size: Google instead shrinks a whole page's content until its widest table fits, to about 54% in a probe of a 1,334 px table, which makes a page of text tiny for one table. The HTML download is the PDF's document, so it fits too. doc-export.test.ts pins the fit, its layout and the twin.
The PDF's floor is 1 px, not the editor's 25 px (MIN_TABLE_COLUMN_PX), which keeps a column wide enough to drag. 63 columns at 25 px are 1,575 px, past A4's 642 px column, so the floor alone printed such a table off the paper. TipTap's table draws a column narrower than its own 25 px minimum at 25 px, so the export's renderer draws the table itself (renderFittedTable, export/doc/render.ts), at the narrowest stored width. The doc preview never fits a table, so it keeps TipTap's table and its 25 px minimum, and draws a column as wide as the editor does. A table in a cell fits that cell: its fitted columns less the cell's padding, as the docx reader sizes one. A fixed column doesn't widen to hold a wider one, so it would draw over the cells beside it.
A table whose columns all store widths prints with table-layout: fixed, which renderFittedTable writes into its style, as the docx writes one (gridWidths). WeasyPrint's auto layout, like a browser's, widens a column to its longest word plus the cell's padding, and reads no overflow-wrap: anywhere there. So a table stored at the column's width still ran off the page: on POI's bug65649.docx, whose landscape sections import onto the doc's one portrait page, tables stored at 604 px laid out at up to 1,284 px and a 63-column one at 2,230 px, their right-hand columns printing nowhere. Fixed, a long word breaks inside its cell instead, as Word breaks one in a fixed table. A cell narrower than its padding draws its text over the border, as the editor does at the 25 px floor. That file now prints in 472 pages where it printed 273, as its tables wrap into narrow columns. A table with a column of no width, such as a new one never dragged, keeps its min-width and the auto layout the editor draws it with.
The PDF's HTML places every band before the body as a WeasyPrint running element (position: running(header)), and the head's CSS shows them in the @top-center and @bottom-center margin boxes over the text column, the page's headerDistance and footerDistance from the paper's edge, page 1's under @page :first when the page differs (bandsCss, export/doc/transform.ts). Before the body, because element() takes the element placed on its page or the last one placed before it: a footer placed after the body would print on the last page only. The element() rules stay in the head, which the sanitizer never sees: it strips element( from a body's CSS as a fetch. A page number renders empty in the PDF, a band's or the body's, and the head's ::after draws counter(page) or counter(pages) (PAGE_NUMBER_CSS). WeasyPrint counts both in the body's flow as in a margin box, so the rule goes in every PDF's head, not only a banded doc's: a doc with no bands holds a page count in its body wherever its docx's NUMPAGES sat.
A margin box pushes nothing, so a band taller than its margin would print over the body, where Word and the editor move the body down. So weasyprint-render.py first lays out a probe of the bands alone (band_margins): the doc's HTML under a user stylesheet that hides the body and forces a second page, so page 1's bands and the other pages' both lay out, on two near-empty pages. A band needs its distance from the edge plus its height, as the editor's margin measures it; its last block has no space after (PAGE_BAND_CLASS, DOCS.md), so a one-line header fits the 2 cm margin. The doc's one render then takes a user stylesheet that grows @page :first to page 1's need and @page to the other pages', !important because a user rule loses to the document's own, so a short letterhead keeps page 1's body high and a page that page 1's growth spills onto still clears its own header. The growth stops where the margins would leave the body less than half the page: a band taller than that runs over the body rather than leaving it a line per page. A doc without bands pays for no probe, as the script looks for PAGE_BAND_CLASS first. The probe reads WeasyPrint's layout boxes, which are not its public API, so an AttributeError or ImportError there grows no margin: a WeasyPrint upgrade that moves them prints a tall band over the body rather than failing every PDF export, docs, sheets and decks alike. Over the 257 corpus docx files whose import gets a band, each doc lays out once, where 187 laid out twice before the probe; the total time stays within 3 % (885 s to 907 s), as the probe costs about what the second layout cost a small doc, and the longest render stays near 33 s, inside the 60 s timeout. Seven print a band over the body, each one whose bands are taller than half the page, which without the cap stretched the worst from 44 pages to 64.
Browsers ignore running(), so the HTML download places the bands where the long page shows them: page 1's header before the body and the other pages' footer after it, a page number as its #, the body's too, as the drive preview shows it.
A footnote (DOCS.md) renders two ways from renderDocHtml. The PDF writes it in place as the editor's spans (pageFootnotes), and the head's CSS (PAGE_FOOTNOTES_CSS, export/doc/transform.ts) floats it with WeasyPrint's float: footnote to the foot of the page its reference falls on, as Google prints notes: a superscript number in the text and at the note, the notes at 10 pt (FOOTNOTE_LOOK) under a 2-inch rule. The note inherits from its reference's paragraph, so its paragraphs and its marker set its look, and the note's own font sets no line, so a note in a title adds no title-sized line to the foot. The call keeps the size of the text it sits in. The rule is an SVG background, because WeasyPrint draws a footnote area's background at the image's own size and reads no background-size. In every PDF, a heading that holds a note labels its bookmark with its own text (data-bookmark), since WeasyPrint's label would end in the note's number.
On screen, the HTML download and the drive preview write each reference as a superscript number linking to its note in a list after the body, each note ending in a ↩ that links back (footnoteList, export/doc/render.ts). .eigen-prose a underlines every link, so eigen-prose.css takes the line off these two by their #fn- and #fnref- hrefs, and a link in a note's text keeps it.
A note floats whole to its reference's page, so a note taller than the page's text lost its lines past the foot, and WeasyPrint lays the notes out again for every page they fill: 4,000 one-line notes took 35 s and 10,000 ran past the 60 s timeout. So a PDF with more than MAX_PAGE_NOTES (1,500) notes, or with one note longer than a page (notesFitPages, export/doc/transform.ts: its paragraphs and characters at 10 pt in the tallest bundled face, a picture at its height, against the page's lines), lists every note after the body, as the HTML download does; 4,000 notes print so in 18 s.
Browsers ignore float: footnote, so the HTML download and the drive preview write each reference as a superscript number linking to its note in a list closing the body (section.footnotes), each note linking back at the end of its last paragraph, or after its last block, as Google's HTML download does. Both forms number the notes in document order, the order footnotesInOrder reads, and a nested note renders as its text (flattenNestedStories). Only a doc with notes gets either stylesheet. A band holds no note: readEigendocBands drops one a peer forged.
A figure is an inline node, so it renders as a span.figure with a span.figcaption, which a paragraph can hold. A <figure> inside a <p> closes it in every HTML parser, the sanitizer's and WeasyPrint's, and the paragraph split around it gains margins the editor never draws. span.figure parses back with its caption and layout; the <figure> rule stays, because the editor's own copy still writes one. Both draw from the shared .figure box (DOCS.md). A chart takes the same box with inline SVG in place of the image, never an <img>, because an SVG image can't reach the page's @font-face and its labels would fall back to another font (CHARTS.md).
withTrailingBreaks (export/doc/render.ts) gives a paragraph, heading or code block the trailing <br> the editor has, in the export and the preview alike (DOCS.md).
The static renderer nests a text's marks the other way round from the editor: it wraps the first mark innermost, where ProseMirror draws it outermost. Rendered as is, a link's own color would sit outside its <a>, and .eigen-prose a would draw the link color over it. inEditorMarkOrder (document/doc-schema.ts) hands the renderer the doc over a schema whose marks rank in reverse, so the export and the preview nest marks as the editor does: the <a> outermost, its color inside it.
renderDocHtml maps the textBox node to the frame's span holding the box's span and a span per paragraph (renderTextBox, export/doc/render.ts), each paragraph rendered as elsewhere and written as a span carrying its <p>'s attributes, so the HTML, the PDF and the preview draw what the editor draws. WeasyPrint lays out a flex column with min-height and justify-content inside an absolute box, so a box keeps its minimum height and its vertical alignment on paper. On paper a heading that holds a box bookmarks its own text, as one that holds a note does.
A figure in front of or behind the text (DOCS.md) renders as the same span.figure, with its left, top and width inline from figureLayoutStyle, which the sanitizer keeps. The stylesheet makes it an absolute box in its paragraph, over the text or under it. The export's .page is the layers' stacking context, as the editor's page is, through position: relative; z-index: 0 rather than isolation: WeasyPrint 70 ignores isolation with a warning on every render, and a behind figure under a page with a background then vanishes. A figure in a table cell, a note or a band renders in line, as a block, since none has a body paragraph to anchor on (withBodyOnlyPositions in export/doc/render.ts, and the band option renderEigendocExport passes for a header or footer part).
WeasyPrint carries no absolute box to the next page, and a probe of 70.0 pins what it does instead. It draws both layers, and a box past the left or right margin, clipped only at the paper's edge. A paragraph split across pages draws its figures from its first fragment, on that page only. A box that crosses the page's end draws into the bottom margin, clipped at the paper's edge, and a box whose top lies past the paper is left out. So a figure near a page's end prints cut or not at all, which the help center says. .figure { page-break-inside: avoid } does nothing for an absolute box.
The HTML download and the preview draw an equation as the MathML the editor holds, in the editor's span, without its LaTeX annotation, which no screen draws. WeasyPrint draws no MathML, so the PDF draws each one as an SVG that MathJax 4 (@mathjax/src) makes from that same MathML string. renderEigendocExport (export/doc/transform.ts) runs an async pass before the render: it walks the equations in document order, parses each distinct one and has mathmlToSvg (export/math-svg.ts) draw it once, and hands renderDocHtml the drawings keyed by stored string and display. The equation mapping looks each equation up by its own key, so a subtree the render skips, an equation the schema refuses in a code block, a figure or another equation but a Yjs update can hold, moves no drawing onto another equation.
- MathJax loads on the first equation, once per transform process, so an export with none never evaluates it. It loads a font range the first time a character needs one, so every equation goes through the promise API.
- Every glyph is a path of its own (
fontCache: 'none'). A glyph cache would write each glyph's id in every equation's<defs>, and one HTML document would hold the same id many times. An inline equation is one SVG: MathJax's inline line breaks would split it into several around an element the sanitizer drops. - The SVG goes through
sanitizeExportHtmlwith the rest of the body, which keeps all of it butfocusable. MathJax'svertical-alignputs an inline one on the text's baseline. A displayed one (span.math-svg[data-display],printExtras) takes its line as in the editor: a full-width box, centred, on the bottom of the line the paragraph's trailing<br>holds, so no empty line follows it. Hiding that<br>instead would hide a user's own break after text that follows the equation, since CSS's+skips text. MathJax breaks no equation into lines, so one wider than the column scales down to it, where Word and Google Docs wrap it. - Every render bounds its equations by element, every copy counted, because the sanitizer's jsdom and WeasyPrint pay per element, not per byte. The bands and the body spend one budget (
renderEquations), in the order the export writes them, so a header's equations print on every page as SVG too. jsdom runs in the transform child process, where memory is the limit (DOCUMENT-TRANSFORMS.md). The PDF draws at most 50,000 elements and 10 MB of SVG (SVG_LIMIT,export/math-svg.ts). 44,000 elements of empty fences are 1.2 MB, and take the transform process 0.56 GB and WeasyPrint 9 s; 6 times as many, which fit 10 MB, take 1.9 GB and 51 s. Glyph paths run about 390 bytes an element, so there the bytes bind first. One equation at the math bounds draws up to 4.1 MB of SVG, a row of 1,999⨌, so the budget holds two. The HTML download and the preview, which a viewer's open runs, write at most 50,000 elements of MathML (MATHML_LIMIT,export/doc/equations.ts), at 3 to 5 KB of jsdom each: one paragraph of 294,000 elements is 1.6 GB of preview, and 4.9 million elements 16 GB of download. - A 1,000-equation paper of the 108 corpus equations draws about 21,700 SVG elements and 4.2 MB, in 8 s of WeasyPrint, and writes about 10,900 MathML elements, so it shows every equation. The PDF's pass stops calling MathJax at the first equation that does not fit, so it holds at most the budget plus that one drawing.
MAX_SVG_EQUATIONS(1,000) bounds the MathJax calls, at 1 to 2 ms each over the corpus after about 0.12 s to load (measured on a warm file cache), and up to 0.6 s for a tree at the math bounds. From the first equation past a bound, the rest print as their linear text,readStoredEquation'stext, typed or from Word alike, as the search index reads them (DOCS.md). Text costs no element.
The tests are in apps/api/src/test/export/math-svg.test.ts, the equations blocks of doc-export.test.ts, the preview's budget in preview/eigendoc-render.test.ts, and the WeasyPrint block of document-export-route.test.ts, which prints an equation through the transform process.
WeasyPrint reads <ol start> and <ol type> only as presentational hints, and the PDF render takes no hints, so every list would print from 1 in decimals. A stylesheet can't stand in for type: HTML matches the attribute case-blind, so [type="a"] matches an A list too, and Chrome reads no s flag to tell them apart. So the schema's ordered list (EigenOrderedList, packages/lib/src/docs/eigendoc/nodes/ordered-list.ts) writes both into its own style: counter-reset: list-item <start − 1> on a list that starts elsewhere, and list-style-type for a lettered or Roman one. The editor, quick look, the HTML and the PDF all render it, and it keeps start and type, which a paste reads back.
writeDocx (export/doc/docx/to-docx.ts) writes the WordprocessingML by hand from the JSON readEigendocFromDoc returns, the reader's mirror in export/doc/docx/: paragraphs.ts the blocks and their properties, runs.ts the runs, marks and links, numbering.ts the lists, tables.ts the tables, drawings.ts the figures, styles.ts the styles, fonts.ts the font table and its embedded faces and package.ts the document's section, settings and note separators. It packages them with writePackage (ooxml/opc.ts, over writeZip), the way xlsx is written from the workbook and not from HTML. Fed the export HTML, a converter keeps a doc's structure and loses almost every visual property. Every size, margin, color and border comes from eigen-prose.css through proseValue (document/prose-css.ts), so the editor's look lives in one file and the docx keeps the editor's spacing. proseValue throws when the CSS sets no such value, so a renamed rule fails the export tests instead of drifting. A var(--doc-…, x) reads as x: the CSS is Eigen's look, and the doc's own Normal reaches the writer as the style map.
No schema checked the CRDT's JSON, so the writer checks attrs where it uses them and never fails on structure: inline content where a block belongs is wrapped in a paragraph, and a nested block is hoisted out. A node or mark it has no mapping for throws, as the HTML export does, and apps/api/src/test/export/doc/docx/to-docx.test.ts exports a doc holding every node and mark of the schema, so a new node fails a test instead of vanishing from the docx. A comment mark writes nothing and its text stays: comments are not exported, an open DOCS-TODO question. A page number writes Word's PAGE field and a page count its NUMPAGES field (PAGE_NUMBER_FIELDS, ooxml/wordml.ts) as a complex field, w:fldChar runs around the instruction and the result, as Word and LibreOffice write it: Google Docs leaves a w:fldSimple PAGE in the body blank. The field's runs take no marks (the editor keeps none on a page number) and the result caches 1, which Word, LibreOffice and Google Docs replace as they lay out the pages; the docx reader reads the field back as the node. Every zip entry carries the DOS epoch as its date, so one doc always exports to the same bytes.
An equation is Word's own math: the m:oMath that writeOmml writes from the tree readStoredEquation reads, beside the paragraph's runs rather than inside one. A displayed one is wrapped in m:oMathPara in a paragraph of its own, as Word's Change to Display leaves it, so it splits its paragraph as a block figure does: the pieces share the paragraph's margins, a heading's later pieces stay out of the navigator, and a list item's number stays on the first. LibreOffice draws an m:oMathPara that shares its paragraph with text inline. An empty equation stays an empty m:oMath, and the typed LaTeX stays behind, because OMML has no slot for it (DOCS.md).
A docs chart is a Word chart: a part of its own under word/charts/, which writeChart writes from the spec (CHARTS.md), shown by a w:drawing placed, sized and captioned as a figure is (wordChartOf, export/doc/docx/drawings.ts). Its labels name the spec's font, and the docx embeds that face as it does a run's, so a reader that has the font draws it; that Word's chart engine draws with an embedded face is unverified. The part's text sizes and line widths are the chart's at the scale the figure draws it at, so a chart shrunk to the column keeps its proportions. Its numbers go in a workbook of its own beside the part, as Word writes a chart, so Word's Edit Data opens them, unverified in desktop Word (CHARTS.md). A part paints no sketch, hatch or dash, so those draw plain, as in an xlsx. A spec that doesn't parse, or holds range data a doc can't read, writes its caption alone. The docx import reads the part back as the same chart and look, its series ids by place (CHARTS.md).
A code block's language rides a hidden paragraph style of its own, Code Block (<language>) based on Code Block (codeBlockStyle in apps/api/src/lib/ooxml/wordml.ts), written only for a language lowlight knows. Word keeps a paragraph's style on a re-save, and LibreOffice keeps its name, so the reader reads the language back from the name. The style is w:semiHidden without w:unhideWhenUsed, which keeps it out of Word's style gallery once used. Google Docs drops every custom style, so a doc that went through it comes back with no language.
The header and the footer are parts of their own, header1.xml and footer1.xml, written by the body's block writer in the body's column, and page 1's beside them when the page differs, with w:titlePg in the section; a page-1 band the doc lacks is an empty part, as Word writes one. A part resolves the r:ids it uses through its own relationships part (relsPathOf, ooxml/opc.ts), so a header's image or link has its own relationship, while the image file is written once for the package (officeImages, export/export-image.ts). The Package walk carries the relationships of the part it writes (pkg.part, a partRelationships), and drawing ids stay the package's, so no two collide. A part's root declares the document's namespaces, m: included, as a band may hold an equation. The section's w:header and w:footer are the page's headerDistance and footerDistance, which a docx import reads back. A band's last block is written with no space after, as the editor and the PDF draw it (PAGE_BAND_CLASS) and as Word's Header and Footer styles have none, so a one-line footer stands one line high.
A footnote writes Word's w:footnoteReference run in the FootnoteReference style where it stands, and its note goes to footnotes.xml, numbered 1, 2, 3 as the body meets them, the order footnotesInOrder reads. The part opens with Word's two separators, notes -1 and 0, which w:footnotePr in the settings names, as Word writes them; a doc without notes writes neither. Each note is written by the body's block writer in the body's column, in the FootnoteText style, through the part's own relationships as a band is (§ A band is a docx part), so a link or an image in a note resolves; its first paragraph opens with Word's w:footnoteRef number and a space. A nested note is its text (flattenNestedStories). FootnoteText is Word's own, 10 pt, single spaced with no space after (FOOTNOTE_LOOK, the PDF's note size), and FootnoteReference is superscript.
The writer puts a textStyle's caps on the run as w:caps or w:smallCaps, and the reader turns them back into the attribute, so the letters stay as typed both ways (DOCS.md). Both are toggle properties (ECMA-376 §17.7.3), read as bold and italic are: a character style flips the paragraph style's, and the run's own setting wins. No node draws capitals, so a style's caps, a Title's say, become the mark on its runs.
The writer puts a textStyle's font size on the run as w:sz and w:szCs in half points. The reader keeps a run's size where it differs from the one its node draws: the docx's own body size (Normal over the document defaults) or a heading's, a title's or a note's style. A size equal to the one the node draws in Eigen is no mark either, a 10 pt run in a note say or a 10 pt run in an empty heading that comes in as a paragraph of a 10 pt Normal: the writer's style holds that size, so the mark would not come back (ownTextLook, document/looks.ts). So a Word file with a 12 pt body, a quarter of the corpus, imports without a size on every run, its body at 12 pt as the doc's Normal, and its headings at the doc's heading sizes. Small is written as its look, 9 pt and a little spaced, and reads back as a 9 pt size. Both ways go through readFontSize, so a hostile w:sz lands in 1 to 400 pt (DOCS.md).
Word has four slots per family (Regular, Bold, Italic, Bold Italic) and reads no variable font, so the writer embeds static TrueType files that sit beside the variable WOFF2s in packages/ui/src/assets/fonts/ (DOCX_FONT_FILES in export/doc/docx/fonts.ts). The editor draws bold at 600. So Inter's and JetBrains Mono's Bold slots hold their 600 files, renamed to Bold so the family stays one name that Word, an import and Google Docs all find. Source Serif 4's holds its 700, and Excalifont has a Regular only, from which Word synthesizes the rest.
Only the faces a doc's runs resolve to are embedded, with the body's Regular always among them, each whole and not subset, so a reader can type new characters. Each is obfuscated as ECMA-376 requires, its first 32 bytes XORed with a GUID key hashed from the face, so the bytes stay stable. MS Gothic, which draws the task checkboxes, joins the font table only with a task list and is not embedded.
CSS sets a line as a multiple of the font size. Word's auto line is a multiple of the font's own line height, (usWinAscent + usWinDescent) / unitsPerEm from its OS/2 table, and atLeast would be read as single spacing by Google Docs. So autoLine divides the CSS pitch by that height, and every style's line is the editor's pitch in Word. Word also scales an auto line with the tallest face in it, by that face's own line height, while the editor's unitless line-height scales with the largest size alone. So a paragraph whose runs all draw in one family gets its line rescaled by the editor's pitch over Word's (familyLineScale, document/looks.ts): the mark's family at the largest size, over the tallest face, the mark's included, each at its size. That moves the line wherever the two differ, a taller family under the mark or larger runs in a shorter one, so Source Serif 4, JetBrains Mono and Excalifont keep the editor's pitch. A larger size in the mark's own family keeps the style's line, and so does a paragraph of mixed families. Each family's height is its EIGEN_FONTS line (fontLineHeight, packages/lib/src/constants/fonts.ts), which fonts.test.ts checks against its TTF and the docx reader shares.
A paragraph's own format (DOCS.md) goes on top of its style's and its container's: its line spacing as an auto line through autoLine, which comes to 240ths of the multiple for Inter, before and after in twips outside a list, quote or cell, which own the spacing of what they hold, and its indents as w:ind, the left one counted from the container's text, a negative first line as w:hanging. A list item's first paragraph takes its indent from the numbering level, so an indent set on it stays in the editor. w:docDefaults and the Normal style are the doc's Normal: its font, colour and size as the default run, its before, after and line spacing as Normal's, the line through autoLine in the Normal's font, as the headings' and the title's lines are, which draw in it too. Eigen's own, line spacing 1.4 and 11 pt after, is an auto 336 and 220. The styles are built per export on that Normal, so the writer's spacing and line decisions read the doc's. A paragraph in a list, a quote or a cell, and the Quote style, write a before of 0 when the Normal has one, as the editor draws none there. Spacing eigen-prose.css gives in em (a list's, a quote's, a figure's margin) is written at the 11 pt body's, where the editor scales it with the Normal's size.
The PDF and a docx, pptx or xlsx send each raster as a JPEG or PNG made from its source file, never as the WebP screen preview the viewer shows. Word for the web, PowerPoint for the web and Google Docs, Slides and Sheets draw no embedded WebP (Excel for the web and LibreOffice do). WeasyPrint embeds a JPEG's own bytes under DCTDecode and decodes nothing, but it decodes any other raster and deflates it into the PDF again: a 2560 px WebP of a photo cost it 0.61 s of CPU and the same photo as a JPEG 0.014 s, and 40 such WebPs made a 47 MB PDF where the JPEGs made 7 MB.
The HTML download keeps the cached WebP preview (previewMedia, export/media.ts). A browser opens it, and every browser draws a WebP. The WebP is smaller than a JPEG of the same photo, it comes from the cache with no re-encode, and an animated GIF's preview still moves.
prepareMedia (export/media.ts) re-encodes each image from its source in the thumbnail Worker, by the rules of encodeExportImage (export/export-image.ts). The picture is at most 2560 px on the long side (EXPORT_IMAGE_MAX_SIZE). A photo becomes a JPEG at quality 80. A lossless source (a PNG, a GIF, a VP8L WebP), such as a screenshot or a chart, becomes a JPEG at quality 90, as its text and hard edges show what a lower quality smears. A PNG is many times the JPEG for such a source: a 3456 by 2234 macOS screenshot is 6.8 MB as a 2560 px PNG, 0.77 MB at quality 90 and 0.44 MB at 80, and at 4 times zoom quality 90 adds only a faint speckle around the glyphs. Only a source with pixels that show through becomes a PNG, which WeasyPrint prints with a soft mask, as JPEG has no alpha. That is read from the pixels, not the header, because a macOS screenshot carries an alpha channel that is opaque throughout: when it writes a JPEG of a source with the channel, sharpResize decodes the resized picture to raw pixels once, reads its alpha, and encodes the JPEG from those pixels, so the test costs no second decode of the source. It reads the picture as sent, at most 2560 px, not the source: for an opaque 16,000 by 16,000 16-bit interlaced PNG of 11 MB the JPEG took 4.7 s where sharp's stats of the source took it to 10.8 s; the peak of 2.5 GB is the decode of that interlaced source, which the resize needs either way. So Word, PowerPoint and Excel get a JPEG part (imageN.jpeg) for an opaque PNG.
The re-encode drops EXIF, GPS included. It reads the source whole, so a source past MAX_DECOMPRESSED_BYTES is left out unread: the PDF draws nothing for it, as for an image no reader can decode, and a docx, pptx or xlsx counts it as images-dropped. The re-encode is uncached and runs on the thumbnail semaphore uploads and previews share, which allows 4 workers (ROADMAP). collectExportMedia loops over the images in sequence, so one export takes one image at a time and queues no more once the client disconnects. encodeExportImage returns the size of the picture as sent, shrunk to fit 2560 px, which sizes a docx drawing, because a figure stores only its width, places a pptx picture its box covers or contains, and gives a sheet's PDF the pixels it charges (§ A sheet export recalcs).
The re-encode costs what the cached preview did not: 0.14 s of CPU a photo in the Worker, measured on the Office corpus's photos, JPEGs of 1 to 2.7 MB and up to 4,267 px. WeasyPrint saves far more, at a load average of 12 to 28: a sheet of 20 such photos printed in 4 s instead of 17, its WeasyPrint run fell from 15 s of CPU to 1.5 and its PDF from 28 MB to 4.5. A sheet of 40 printed all of them in 6 s, where WebPs took 21 s and the pixel charge still left 14 out. A corpus docx with seven photos printed in 4 s instead of 5, 0.75 MB instead of 2.85 MB.
An SVG goes in as itself, an asvg:svgBlip that Word, PowerPoint and LibreOffice draw, beside a PNG fallback for a reader without SVG. withSvgFallbacks (export/export-image.ts) draws that PNG in the transform process from the sanitized XML the svgBlip carries (§ The sanitizer), so the Office file shows what the PDF shows. In the transform process sharp loads only for an Office file that holds an SVG, and for a doc export with a pasted image. One librsvg cannot read leaves the file, a docx keeping its caption, and so does one it cannot draw within 30 s. That timeout is sharp's own, so a slow SVG costs its image and not the export: a 310-byte filter chain held the one transform slot for 90 s. sharp reads physical units (in, cm, mm, pt, pc) at 72 dpi, so cssSize scales a side the root <svg> gives in one by 96/72, the size the HTML export draws.
librsvg draws an SVG's text in the system's fonts, and the API image's own is DejaVu alone, wider than the faces Eigen measures text in and without CJK. So the image carries copies of the bundled TTFs (docker/api/Dockerfile), for the text in an SVG's PNG fallback in an Office file, such as a canvas selection pasted into a doc, and in an SVG's thumbnail. Chinese and Korean draw in Noto Sans SC's and KR's slices, which the build decompresses to TrueType with the WeasyPrint venv's fontTools, because sharp's own libvips has no brotli and its FreeType reads no WOFF2. A slice names its family "Noto Sans SC Thin" or "Noto Sans KR Thin" to fontconfig, the name of its default instance, as subsetting dropped the typographic family name, so an SVG that names Noto Sans SC reaches the slices by fontconfig's fallback on the character, not by its name. WeasyPrint still prints a document's CJK from the HTML's own @font-face slices, which it adds to fontconfig under their CSS family.
A figure pasted with a data: src has no media file, and the main thread never reads the doc (DOCUMENT-TRANSFORMS.md), so the transform process decodes each such src once (pastedFigureMedia, export/doc/pasted-media.ts), for the docx and for the HTML and the PDF alike. A raster goes through encodeExportImage and the thumbnail Worker's own sharpResize, so it drops its EXIF, GPS included, and shrinks as a media file does, and an animated GIF draws its first frame; an SVG goes through sanitizeExportMedia, and in a docx withSvgFallbacks. A src no image decodes from draws nothing. The writer and the HTML name the item by its src (figureMediaKey), which no media name can equal, since a media name holds no /. Only a ;base64 src is read; a percent-encoded one writes only the caption in a docx, and in the HTML and the PDF draws as it is if it is an SVG and draws nothing otherwise, so a percent-encoded JPEG takes no EXIF out. An SVG is sanitized, not re-encoded, so a raster it embeds as an <image href="data:…"> keeps its EXIF.
Observed in Word's render on 2026-10-08: a 1204×4 px divider drew 466.5 pt wide until its height became whole twips. So the writer rounds the height to whole twips itself. A figure with no media, an external src (a docx fetches nothing) or a media item without a size or fallback writes only its caption.
The editor draws a list beside a wrapped figure, narrowed to the room the float leaves (DOCS.md), because the list is its own formatting context. WeasyPrint lays such a box out at its containing block's whole width and then moves it down past every float it doesn't fit beside (avoid_collisions in its layout/float.py); only a line box narrows. So the PDF starts the list below the figure. As a plain block the list would run beside the float, but with its markers under the image and every level at the float's edge. doc-export.test.ts pins the PDF's list below the figure, an open DOCS-TODO gap.
A table taller than what is left of a page starts on that page and goes on over the next, as Word and Google Docs print one: the print CSS (printExtras in export/doc/transform.ts) keeps neither the table nor a row whole. A table kept whole jumped to the next page and left the one before half empty, and a row taller than what is left splits too, as Word's "Allow row to break across pages" is on by default. A quote that holds a page break may split. No header row repeats on the next page yet, where the docx marks a leading row of header cells to repeat (w:tblHeader), an open DOCS-TODO row. doc-export.test.ts pins a 60-row table, and a row of 60 lines, after half a page starting on page 1.
A figure wrapped left or right becomes a borderless floating table of one cell holding the image and its caption, placed before its paragraph, its distances from the text the figure's float margin in eigen-prose.css. It is the one wrap that keeps the caption under the image in every reader, and its row cannot split, or Google Docs moves the caption to the next page. In a list or quote a left float starts at the container's text edge, clear of the bullet and the bar.
A figure or a chart in front of or behind the text (DOCS.md) is a wp:anchor run with wrapNone, behindDoc set for the layer under the text (anchoredFigure, export/doc/docx/drawings.ts). It opens its paragraph's first piece, as Word anchors a picture at its paragraph's start, and the paragraph isn't split around it: a paragraph that holds only anchors still keeps its line, as the editor's does. Its children go in the schema's order, as Word reports an anchor out of order as unreadable content. relativeHeight is a base plus the drawing's id, so Word stacks each layer in document order, as the editor does. The width caps at the paper, not the column, and no caption is written. In a table cell, a note or a band, where the editor draws it in line, it is written as a block figure (positions on the writer's context, set for the body alone).
Eigen counts the offsets from the paragraph's padding box, and Word from the text column and the paragraph, so the writer converts through two helpers in document/looks.ts, which the reader inverts:
| Offset | Written as | Helper |
|---|---|---|
| X | positionH relativeFrom="column", offsetX plus the paragraph's indent: its list's or quote's, as the writer indents them, and its own left indent, except on an item's numbered paragraph, whose own indent Word replaces with the list's and the file doesn't hold |
anchorIndentPx |
| Y | positionV relativeFrom="line", offsetY less the padding Eigen draws above the text: the paragraph's own space before, else the Normal's for a paragraph and none for a heading or a title, and none in a list or a quote |
anchorPaddingPx |
Y counts from the first line, not the paragraph. LibreOffice counts a paragraph-relative offset from the end of the space after the block above, so a paragraph-relative place would move with how each reader spaces two paragraphs. Word's origin for either is unverified (DOCS-TODO.md): if it differs, the fix is one formula.
A doc round-trips through its own docx within 1 px, in both layers, in a plain, an indented, a list, an indented list and a quote paragraph and after a heading (apps/api/src/test/import/doc/round-trip.test.ts). A Word file that goes through Eigen and back does not: § A Word picture in front of or behind the text comes in placed from its paragraph lists what changes.
The docx writes a text box (DOCS.md) as Word writes one: a wps:wsp shape with wps:cNvSpPr txBox="1" and its paragraphs in wps:txbx/w:txbxContent, written by the body's block writer in the box's own column with no space between paragraphs, as a cell's (export/doc/docx/text-boxes.ts). A note or a box forged inside a box writes as its text, through flattenNestedStories, because Word and LibreOffice refuse a file with a note reference or a shape inside w:txbxContent. One in front of or behind the text is a wrapNone anchor at its paragraph's start, placed through anchorIndentPx and anchorPaddingPx as a positioned picture is; a wrapped one a wrapSquare anchor aligned left or right in the column at its paragraph's top, the text keeping the wrapped figure's CSS margin from it; one in the flow a wp:inline in a paragraph of its own, aligned by w:jc, as a block figure. In a table cell a positioned box is written in the flow, as the editor draws it. The anchor head is the figure's (drawingHead in docx/drawings.ts). wps:spPr holds the fill, the outline (a:ln, a:prstDash for dashed and dotted, cmpd="dbl" for double) and rect or roundRect, whose radius is Eigen's 8 px as a share of the shorter side. A dotted outline is sysDot (BORDER_DASH_BY_STYLE): its dots stand one width apart, as a CSS border's do, and Word writes it for its own round-dot outline. A Vector or Slides stroke's sparser dots are dot. wps:bodyPr writes wrap="square", so every reader wraps the text inside the box, the insets and anchor for the vertical alignment.
A box's height is a minimum in Eigen and fixed in Word, which clips the rest. So the writer estimates the content's height over the box's column (contentHeight): each paragraph's characters at its largest run's size, never below the Normal's, on the Normal's line, and each picture or chart as tall as the drawing writer draws it there, at its stored height's ratio or its own. A box that has no height, or whose estimate passes it, writes a:spAutoFit at the larger of the two, which Word and LibreOffice refit on layout. One whose height holds its content writes a:noAutofit at that height, so a callout with a 14 pt title in an ample box keeps its height. A grown box reads back with no height, its minimum lost.
The shape sits in mc:AlternateContent/mc:Choice Requires="wps" with no VML mc:Fallback, as Word for the web writes its own boxes, and every document part declares xmlns:wps. LibreOffice reads it (U0 spike: place, size, fill, dash, rounded corners, vertical alignment and the refit of spAutoFit); Word 2007, which knows no wps, drops the box and its text.
In the editor a paragraph's own margins collapse with its neighbours', and a figure's 0.75em margin sits inside the paragraph and never collapses. Word and LibreOffice collapse adjacent spacing to the larger, as CSS does, and have no inside margin. So the docx writer carries the figure's margin as an inset on its paragraphs, and withInsets writes the whole gap on the inset paragraph's side: the neighbour's inset, the two spacings collapsed, its own inset. The pieces a figure splits a paragraph into share its margins, the first taking the one above and the last the one below. The reader undoes it: a block figure's paragraph keeps the space above it past the figure's own margin (FIGURE_MARGIN) as its spaceBefore, unless the block above already leaves that space below it or the paragraph's style draws it anyway, so a heading split at its figure keeps its space above on a round trip. A paragraph that holds only a wrapped figure keeps its line, as the editor's trailing break does. In a quote, a paragraph whose style isn't Quote (a figure's, its caption's, an item's clearing Spacer, a done task) draws the quote's bar itself at the quote's indent, so the bar runs unbroken past it.
A list item that holds a wrapped figure ends in a clearing break (w:br w:clear="all") on a single-spaced Spacer paragraph, so the next item starts below the float, as in the editor. A nested item clears its own floats, so the item around it writes no second break. LibreOffice ignores the clear on an exact-height Spacer. Google Docs ignores the clear too, so the next item draws beside the float there (observed 2026-10-08), while Word and LibreOffice honour it. The cases are pinned in apps/api/src/test/export/doc/docx/to-docx.test.ts.
Each ordered list gets its own w:abstractNum with its start in all nine levels, so two adjacent lists count separately. A list that stores no type numbers by depth in the editor (DOCS.md), so the writer reads its format from the same rules, .eigen-prose ol, then .eigen-doc ol ol and on (untypedListFormat, document/looks.ts), counting the numbered lists around it, a bullet list between them or not. The reader matches it the other way: a Word level that draws what an untyped list draws at its depth stores no type, so Google's and Word's 1. a. i. comes in untyped, and any other level keeps its type, a nested decimal one '1'. A bullet counts nothing, so bullet lists at one indent share one definition, which keeps numbering.xml small, and a bullet has no counter to restart. Word has nine levels, so a list or quote deeper still indents no further.
A cell's backgroundColor (DOCS.md) is its w:shd fill, and a header cell without one takes the header's grey from .eigen-prose th, so a cell's own fill wins. Word draws a fill per cell, so a vertical merge's covered cells write their merge's fill too. The reader keeps the fill a cell draws, its own, else its table style's first row's, else the table's, in any color: a fill is the cell's look and never marks a header. A header row is one with w:tblHeader, or a first row its table style fills. A header cell beside a body cell, which w:tblHeader can't mark, writes its plain paragraphs in a hidden Table Header style based on Normal (STYLE_NAMES.TableHeader), as a code block's language rides its style, and the reader reads a cell with one as a header; a header cell holding no plain paragraph, and one Google Docs re-saved, as it drops every custom style, comes back a body cell in the header's grey. Auto text on a dark cell draws white, as Word draws it, and light text keeps its color, since the cell draws the fill it sits on.
The reader resolves each side of a cell (DOCS.md) in Word's order (sideOf in import/doc/tables.ts): the cell's own w:tcBorders, start and end read as left and right; else its table style's first-row tcBorders when the table turns firstRow on; else the table's w:tblBorders for that place, an outer side on the table's edge and insideH or insideV inside; else the table style's tblBorders, each side from the nearest style of its basedOn chain (tableBordersOf, import/doc/styles.ts); else none, as Word's default table style draws none. nil and none are none. w:sz counts eighths of a point, so 4 is 0.67 px, held to 0.5 to 8 px. A dashed or dotted kind is dashed or dotted, a double or thin-thick kind double, and every 3-D or art border solid. A vertical merge takes its bottom from its last row, and the columns a row skips draw no side, as Word draws no cell there. Diagonals and a row's w:tblPrEx are not read. A side in Eigen's own grey reads as unset, so a table the writer wrote, or Google Docs or Word re-saved, comes back with nothing stored.
The writer keeps the grey w:tblBorders for a table whose cells store no side, which every Eigen table did before borders and most still do. A table where any cell stores one writes all four sides on every cell, an unset side in the grey and none as nil, and no tblBorders, since every cell then states each side and a table border would say nothing a cell does not. A merge's covered cells repeat its sides, as Word reads a merge's bottom from its last row.
A doc's table of contents (DOCS.md) stores no entries, so every export lists the body's headings as they are now, through the outline's one rule. renderDocHtml writes it as a nav of one entry per heading to its level, each a link to its heading's h-N id and drawn in its level's look, as the marks of that font, size, bold and italic draw. The nav carries the node's stored attributes, so the HTML download pasted back into Docs keeps them, and data-preset names the layout it draws when that is not Dotted, Links for any page-less doc. Only an export's body gives its headings ids (anchors): the PDF places the bands before the body, where a band's heading would take an id the body's needs, and the Drive preview is live DOM in the drive's page, where a fragment link would move that page, so it writes the entries as text. Links draws its entries in the link colour by the prose stylesheet's rule on data-preset="links", which the editor, the downloads and the preview share, so no entry carries the colour itself.
The PDF's head draws each entry's page number at the right edge (TOC_CSS, export/doc/transform.ts): after a dotted leader in Dotted, leader('.') target-counter(attr(href), page), after spaces in Plain text, and none in Links. The rules for all three layouts are always there, a fixed stylesheet, and in the head because the sanitizer strips attr() from a body's CSS; the sanitizer keeps an entry's fragment href and the heading's id. The HTML download shows no page numbers in any layout, as it has no pages. WeasyPrint's numbers count the table of contents' own pages, and a missing anchor prints no number without failing the render. The review measured the cost on WeasyPrint 70: a 160-page doc with a table of contents rendered in 25.9 s, 20.6 s without, against the 60 s budget.
The docx writes Word's own (docx/table-of-contents.ts): a TOC \o "1-N" \h \z \u field (tocInstruction, ooxml/wordml.ts), with \n in Links, which leaves out every level's page number, in a content control of Word's gallery (docPartGallery "Table of Contents"), so Word's Update Table and its references pane find it. Each entry is a paragraph in Word's toc N style with a right tab stop at the column's edge, its leader dot or none as the node stores it, its text a link to the heading's _TocN bookmark, then, with page numbers, a tab and a PAGEREF field with no result: the writer lays out no pages. So the field's begin is marked w:dirty, and Word asks to update the fields when it opens the file and fills the numbers in; a reader that never updates a field shows the entries without numbers until Update Table. The docx of a page-less doc writes the stored layout, not the Links it draws, as Word lays out pages anyway; in the Web Layout such a file opens in, \z hides the numbers. An entry the surfaces draw as a link, in Links or on a page-less doc, takes Word's Hyperlink character style on its text, the run style Word gives a linked entry, so it shows in the link colour; the import reads looks only from the toc N styles, so the run style comes back as no look. The headings take their bookmarks only when the body holds a table of contents, so a doc without one writes the bytes it did. The writer numbers the headings in its own walk of the JSON (docxOutline), by node identity, so a heading in a band or a note, which no outline lists, takes no bookmark.
The body's first table of contents gives its looks to the toc 1 to toc 6 styles (tocStyleLook), each level's font, size, bold and italic as the style's run properties, and its Plain text tab as theirs. Word keeps a table of contents' look in these styles, and its Update Table writes every entry again in its style, so a look on the entries' own runs would be gone after the first update. A second table of contents with another look comes back from the docx with the first's, which the owner accepted. A level's font is embedded even when that level lists no heading yet, so an entry Word adds draws in it. Whether Plain text survives Word's own Update Table is unverified: Word may write its own dotted tab on each entry it writes again (DOCS-TODO.md). The doc writes the toc N styles only when its first table of contents lists something, has a look or is Plain text.
The import reads a TOC field between the body's blocks, in Word's content control or not, as one node where the field begins. Its levels is \o "a-b"'s b, 6 for a bare \o, which lists every level, and 3 without one. The node stores no lower bound, so a Word \o "2-4" lists levels 1 to 4 here. A \t switch's numbers are the levels its styles list at, not heading levels, so they set none. Every paragraph of the field's result is cut, its links and PAGEREF fields with it, and text before the field's begin and after its end stays in a paragraph of its own (readParagraph, import/doc/paragraphs.ts). A file can end a field late or never, which would take the rest of the doc with it. A paragraph all result is held until the field ends (OpenToc in import/doc/runs.ts), and since Word's result holds no heading and no table, the first heading or table between the body's blocks, or the body's end, ends the cut instead: the field keeps no node, what it held comes back as its paragraphs in place, and it counts as blocks-flattened. A heading whose paragraph ends the field before its text ends the result there, as Word's ends at the first heading, so the node stays and the heading follows it. A field's end inside a text box ends no cut, as a shape's paragraphs are no result: the next heading, table or the body's end gives back what the field held, so nothing is lost. A field given back is closed too, so a table of contents after it is a node of its own. Nothing is read twice, so a SmartArt graphic's or a chart's part, which the reader reads once, keeps its text. A crafted file of thousands of fields would make thousands of node views, each drawing the whole outline, so only a doc's first eight become nodes and the rest stay their paragraphs, counted the same way. A table of figures (\c, \a) and a field in a table cell or a note stay their paragraphs, as the editor puts a table of contents only between the body's blocks. Word's saved entries, each up to the tab before its page number, are compared with the headings the node lists now, whitespace collapsed, as a numbered heading's number is set off by a tab in Word's entry; a difference counts toc-entries-changed, which the toast says as "1 table of contents now lists the document's headings". In the corpus's 31 files with a table of contents, 19 documents, each field imports as one node, so the one file with two gives two, and 12 files count the warning: Word's saved entries were not updated after a heading changed, or Word lists headings by a style the doc reads as body text. Word's TOC Heading style, a "Contents" title above it, is based on Heading 1 at outline level 9, Word's body text, so a style whose chain sets level 9 imports as a paragraph whatever its base names, and the outline never lists the title. Only the style chain counts: the writer sets level 9 directly on a split heading's later pieces, and those stay headings.
The import gives every node the layout and the looks the file holds (tableOfContentsAttrs, import/doc/table-of-contents.ts), once the body is read, as the first entry may come after the paragraph the node stands in. Page numbers are off when the field's \n leaves out every level it lists: a bare \n, or a \n "a-b" whose range covers every level from \o's lower bound to levels (tocFieldPageNumbers, ooxml/wordml.ts). The leader is the first entry's right tab's, a Strict file's end tab included, else the toc 1 style's. A tab with no w:leader has none, as OOXML defines it, and any other leader, dashes or a line, reads as dots. Each level's look is what its toc N style sets along its basedOn chain, stopping before the Normal and the doc defaults, so a template's toc 2 based on toc 1 keeps its look while the body text is no look. A font or size equal to the body's, and a bold or italic turned off, read as no look, so an explicit 11 pt Inter in an 11 pt Inter doc comes back as none and draws the same. Looks are read only by Word's names, toc 1 to toc 6: LibreOffice names its styles in the document's language ("Índice 1"), so a LibreOffice file's looks don't come in, its layout does. Over the corpus's 19 documents with a table of contents, 31 files, every file's stored layout and looks match its XML and come back equal after an export and an import, and three import as Plain text, their entries' tabs having no leader. apps/api/src/test/import/doc/table-of-contents.test.ts takes every attribute out to Word and back.
A page break is a paragraph holding w:br w:type="page" wherever it stands, at the top level or in a list item, a task item, a quote or a table cell.
A page-less doc's settings.xml opens with <w:view w:val="web"/> (WEB_VIEW, ooxml/wordml.ts), Word's Web Layout, where a wide table fits the window. It is the first child because CT_Settings is an ordered sequence that starts with w:writeProtection, which the writer never writes. Google's docx of a page-less doc has no w:view, so this is Eigen's own, for the round trip; desktop Word likely honours it, and Word for the web, LibreOffice and Google Docs likely ignore it (both unverified), showing a paged file whose wide tables run past the margin, as Google's own docx does.
Its tables in the body, outside a list or a quote, write their stored widths, each column within the paper (gridWidths in export/doc/docx/tables.ts), as the editor draws them past the text. A table in a list, a quote, a cell, a note or a band fits its column, as every table of a paged doc does. A paged doc writes neither the view nor a wider table, so its file is the one it was before page-less mode.
The import reads the view back (webView in import/doc/package.ts): a file in Web Layout imports page-less, and its body's top-level tables keep their widths, each column within the paper, instead of being scaled into the column (readTable, import/doc/tables.ts). Any other view, or none, imports paged. The import commit writes every page key, so a paged file imported into a page-less doc makes it paged. Three of the corpus's 351 docx files are in Web Layout, among them the gov.uk Application for Approval form, which imports page-less. to-docx.test.ts, tables.test.ts and round-trip.test.ts pin both directions.
A link's root-relative href means nothing outside Eigen, so the docx, the HTML and the PDF prefix it with the public origin (getPublicOrigin, none on a localhost dev server), and a protocol-relative one gets https:, since a downloaded file would open it as file:. One rule serves all three: absoluteHref in apps/api/src/lib/export/doc/render.ts, which the docx writer calls per link and withAbsoluteLinks applies to the JSON before the HTML render. The docx import reverses it, so an export edited in Word comes back with its links into Eigen: the import job carries the public origin, and rootRelativeHref (apps/api/src/lib/import/doc/runs.ts) turns a link into the instance root-relative again. It returns a path only when one / follows the origin, so https://<origin>//other.host stays absolute instead of becoming a protocol-relative link that leaves the instance.
PDF is the HTML document through WeasyPrint, run by export/weasyprint-render.py through its Python API rather than its CLI. That script hands WeasyPrint a URL fetcher that opens data: URIs and refuses everything else: http(s), file:, a plain or relative path, any other scheme. Export embeds every resource it needs as a data: URI, so any other reference came from a collaborator's CRDT string, and WeasyPrint would fetch it from the API host while it renders. WeasyPrint's default fetcher opens every one, from a nested <image href> in a data: SVG to an <a rel="attachment">, whose response, or the server file a plain path names, it embeds in the PDF. The fetcher is the boundary for the PDF; the sanitizer is not relied on for it, and export-pdf-ssrf.test.ts renders raw hostile bodies to prove it.
htmlToPdfruns the script with the Python that imports WeasyPrint 68 or later: the one theweasyprintlauncher's shebang names (a pip venv's, Homebrew's own, throughenvorenv -Stoo), elsepython3. A pip launcher whose venv path is long or holds a space starts#!/bin/sh, so it counts as naming none. When neither imports a recent enough one, a PDF export answers 501. The probe is cached; a spawn that finds the cached interpreter gone, as after a Homebrew upgrade, probes once more. An import that runs past 5 s is killed, because the export routes set no request timeout, and is not cached, so the next export probes again. A kill byBun.spawn'stimeoutreads assignalCode'SIGTERM';killedsays nothing, as it is true even after a normal exit.- It runs under
python -I, so a module in the working directory or onPYTHONPATHcannot load in its place. That also drops the user site, so apip install --userWeasyPrint is not found: install it system-wide, in a venv or from the OS package. - The script subclasses
URLFetcher, which WeasyPrint 68 introduced, rather than passallowed_protocols={'data'}: 68.0 takes the text before://as the scheme, so it refuses everydata:URI. - The Docker image installs its apt packages without their recommended ones. A chain of them, libvips to ImageMagick to ghostscript, brings ghostscript's fonts, Droid Sans Fallback among them, and WeasyPrint falls back to whatever fontconfig has, so production's PDFs would draw text in fonts dev and CI lack. The image has no Debian libvips or ImageMagick either: sharp loads the libvips it bundles, and nothing else calls them.
- The Docker image pins WeasyPrint 70.0 from PyPI in a venv at
/opt/weasyprint, so it renders with the engine development does.docker/api/weasyprint-requirements.txtpins every dependency by hash, for the amd64 and arm64 wheels, and its first line is the command that regenerates it. The build renders a one-line PDF throughweasyprint-render.py, so a system library the image lacks fails the build instead of every PDF export. Debian 13 ships 62.3, which ignores flexalign-itemsand width on the.figurebox and prints a centred figure left-aligned, its caption squeezed beside it. - The document has no base URL, so a relative link stays relative in the PDF instead of becoming a
file://URL of the server's working directory.
WeasyPrint picks among a family's slices by their unicode-range, but on its own it prints them wrong. It tells PDF fonts apart by their family, style and weight, not by their file, so it would embed the first slice's file and draw every slice's glyph ids with it, and Chinese, Korean and emoji would print as other characters. patch_font_embedding in export/weasyprint-render.py makes a font's key and the tag of its embedded file also take a digest of the file. add_font asks for a key on every text box, and a face can be megabytes, so each face is digested once per render. It also turns off WeasyPrint's colour-font path: WeasyPrint draws a COLR font (a font with coloured glyphs) only with a HarfBuzz newer than the image's, and leaves it blank otherwise, while the emoji face paints in the text colour anyway, so every font prints its outlines.
Pango asks fontconfig for the best font. Then it asks for all fonts in order and skips the first, since it already has that one. The slices of one family tie: fontconfig ranks fonts by a list of scores, and the slices are equal on every one. glibc's sort keeps tied fonts in the order they came. The sort of macOS and musl does not. There Pango can skip a slice it never had, and prints its characters as .notdef (the font's empty box for a missing glyph). The image and CI run on glibc, so only a Mac or a musl host shows it. break_font_ties gives each slice file a font version of its own, which breaks the tie on every libc.
Every slice file also holds the space and the no-break space, though only a Latin range declares them (SPACES in scripts/fonts/font_tool.py). Pango keeps a space between two Chinese words in the font of those words, and with no glyph for it there the PDF prints a missing glyph half an em wide, so the space looks doubled. Only the file holds them, because a declared space would make a canvas that loads the font of a space fetch every slice.
The shim reaches into WeasyPrint, which the image pins. A WeasyPrint that renamed these internals fails every PDF with a message, and one that changed a signature fails with a traceback. One whose add_font stopped asking for the key would print wrong glyphs again in silence, which weasyprint-font-slices.test.ts catches: it prints Chinese, Korean, Inter and emoji slices with no system font and compares each printed glyph's outline with its slice's, and it counts the digests of a render.
sanitizeExportHtml (export/sanitize.ts) is DOMPurify plus one rule. Every CSS url(, every src, poster and background, and every SVG href must open a data: URI with no fragment. A url( may also be fragment-only (#id), a reference into the same document, and so may an href on the elements that reference with one: use, the gradients, pattern, filter, textPath and mpath. An image or feImage loads its href as a file, so a fragment there names the document itself. srcset is dropped. A url( that opens anything else costs the whole attribute it is in, and in a <style> element the statement it is in. So do @import, the string forms of image-set(), image(), cross-fade() and element(), which fetch with no url(, and attr(), which can read an attribute as a URL: WeasyPrint fails the whole export on attr(name url).
A browser that opens an HTML download fetches what the document names, from the reader's machine, which tells whoever wrote the reference who read it. A preview body is live DOM in the drive hero, the same beacon at every viewer (PREVIEWS.md). So the restriction runs inside the transform process on every assembled body, and every format inherits it: exportHtmlDocument sanitizes the body of every HTML and PDF document, and keeps a link's target, so a downloaded link opens in a new tab as it does in the editors. WeasyPrint ignores target. A data: URI passes as it came, whatever its type or payload: a browser fetches nothing from an SVG drawn as an image, librsvg draws one from its bytes, and WeasyPrint's fetcher opens nothing but data:. The exception is a fragment. Firefox loads a data: SVG named with one (mask:url(data:…#m), fill="url(data:…#p)", <pattern href="data:…#g">) as a document, not an image, and fetches what that document names, such as its @import. So a data: URI holding a # is refused wherever it stands. A real one never has one: export media is base64, and a # in a percent-encoded payload ends it anyway.
- The rule reads the
url(token, in any case, never aurl(…)pair. A quoted URL can hold a)or a quote, so a pair regex ends early onurl('http://host/a)')and the CSS parser fetches it. Its function tokens and its statement split come from the mail rules (packages/lib/src/core/mail/remote-content.ts). Theurl(admissions differ: a message keeps only a raster image its parser inlined, an export anydata:URI without a fragment, a same-document fragment and the preview's own URLs. - Every attribute value is scanned, not only
style: an SVGfill,filter,mask,clip-pathormarker-*is CSS too. - Backslashes go from
styleand<style>text before the scan. A CSS escape spellsurl(or@importinvisibly to a regex (\75 rl(), but not to the parser that fetches. Any other attribute holding both a backslash and a(is dropped: an escape spellsurl((\75 rl() or a fragment (url(data:…\23 p)), and every fetch needs the literal(that opens a function. <a href>keeps its target, because docs and sheets carry real links and a browser follows one only on a click. A link is not inert to WeasyPrint: one withrel="attachment"is fetched and embedded in the PDF, which only the fetcher stops. jsdom names an SVG<a>in lowercase and an HTML one in uppercase, so the check ignores case.- DOMPurify drops every
<use>. A profile that admits<svg>keeps one whose everyhrefis fragment-only, because matplotlib draws its text with<use href="#glyph">; any other<use>goes. Written as XML (toXmlDocument, SVG media), a<use>also needs every target to exist and hold no<use>of its own. Each reference draws its target again, so nested ones multiply: 1.2 KB of six levels of ten runs WeasyPrint into its 60 s kill. One level is what glyphs need, and it ends every cycle. - DOMPurify's MathML profile drops
<none>, the empty slot of a prescript, which breaks the<mmultiscripts>it is in.KEEP_MATHMLkeeps it for every HTML and PDF document and the doc preview; the namespace check still drops one outside a<math>. - Only the whitespace a URL or CSS parser trims counts as whitespace. A non-breaking space before
#ordata:makes a relative path of the rest. - Every token pattern runs in one pass, because a collaborator's string sizes the scan. Two whitespace runs that could trade characters around an empty match cost their square: 256 KB of spaces after a
url(costs 26 s. - The hooks are added and removed around each synchronous call, so they never leak to another DOMPurify user.
- A media preview serves an SVG as uploaded. A docx carries it as a part of its own, and WeasyPrint and librsvg draw its every
<use>, so the transform process takes everyimage/svg+xmlmedia item throughsanitizeExportMediabefore any arm embeds it, and writes it as XML, without the characters XML can't hold; a file with no<svg>in it is dropped. The main thread hands over the inlined bytes as they are, because sanitizing a big drawing holds jsdom for seconds. Every format takes an SVG of any size within the export's media cap: the export deadline, sharp's pixel limit and the PNG fallback's timeout (§ A JPEG or PNG from the source) bound the work.
A browser backs the rule up: every export response carries EXPORT_CSP (export/sanitize.ts), which allows data: images and fonts and inline styles and nothing else, and exportHtmlDocument writes it into every HTML download as a <meta> too, so it holds once the file is saved.
Previews pass the same function the exact set of their own preview URLs (PREVIEWS.md). The tests are in apps/api/src/test/export/export-pdf-ssrf.test.ts.
Export is the one read that recalcs a workbook nobody computed (SHEETS.md § The editor computes on write), so an import whose recalc failed still exports values. A recalc failure exports the replayed values with a recalc-failed warning. A legacy workbook whose recalc outlasts the 120 s deadline fails the export, an accepted residual.
The HTML and PDF export and the Drive preview render the sheets whose tabs show, as Excel prints a workbook, while formulas and rules still read the hidden ones. A hidden row or column is left out of the table the same way, hidden by its key in rowhidden or colhidden whatever it holds, since the editor and the import store 0. The xlsx export writes every sheet, a hidden one hidden, with its tab color. The HTML and PDF export walk at most MAX_EXPORT_CELLS (250,000) cells of the whole workbook, rows times columns (renderSheetsHtml, export/sheets/render.ts): a peer's merge or border key at XFD1048576, on as many sheets as it likes, would otherwise make every empty cell a <td> that the sanitizer and WeasyPrint pay for. The sheets spend the cells in tab order, and each gets what the ones before it left. A sheet past that keeps its columns from the left up to one PDF page of default columns (PAGE_COLUMNS), the HTML download more while all its rows still fit, and then the rows that fit, so the corpus's widest, a 264 by 14,703 time series, keeps all 264 rows and the first 946 columns in the HTML and 192 in the PDF, whose page would cut off the rest (exportWindow). A sheet left too few cells for one of its rows is left out of the document, its page break and page size with it, and counts a sheets-left-out warning. The PDF charges PDF_PAGE_CELLS for each page a sheet with cells, images or charts prints, so the cap holds 2,000 pages (printedPages). A row starts the next page when it doesn't fit what is left of one, as WeasyPrint breaks a table between rows, so a sheet of rows each taller than half a page prints a page a row, and its images and charts stretch it to the farthest of them. Each row prints about its stored height, as the editor clips a cell to its rows: a table row grows to fit its content, so one wrapped cell of 6,000 lines printed 3,000 pages charged as one, and a 409 pt font on 200 rows printed 34. So a cell whose content can pass its rows, wrapped or stacked text or a line taller than them, is clipped (canPassRows, clipToRows). The content sits in an inline-block as tall as the rows less their gridline, the box the editor clips to, because WeasyPrint pages through a block's content past its height but not through an inline-block's. The text inside is placed absolutely where the canvas puts its ink (TEXT_PLACEMENT): in the middle, or 2 px from the top or the bottom, so text the editor draws whole prints whole, and a centered cell taller than its row loses its top and bottom alike. CSS places a line by its font's ascent and descent, which in Inter put the ink 0.1em below the line's middle, so the placement lifts it back, and wrapped lines take the editor's pitch of 1.1. A clip at the rows' height less the cell's padding, with the text at its top, cut a 12 pt cell's descenders in a default row and a 14 pt cell at its baseline. A clipped cell's td sets line-height to 0, so a large font adds no line around the block, and the block and the text take the td's text decoration, which reaches into neither an inline-block nor an absolute box. A row an import grew to fit its wrapped text keeps all of it, as its stored height already holds it. A turned span is placed absolutely, grows no row and is left unclipped, and a border thicker than a pixel still adds one or two to its row. A clip is two positioned boxes, and on every cell it doubled what WeasyPrint spends: a 39,462-cell survey workbook printed in 37 s instead of 19. So one line that fits its rows with a pixel to spare, such as an 11 pt line in a default row, stays a plain cell that the td's vertical-align places, its ink about 0.1em lower than the canvas puts it, and in Inter it grows its row by two pixels at most. A character Inter lacks takes a fallback font's line, which can be taller: a Chinese, Japanese or Korean one prints in the Noto Sans SC or KR slices, whose line is 1.448 times the font size, so a plain 10 to 11.5 pt line grows a 20 px row by 2 to 5 px, and a sheet of such rows prints up to a quarter more pages than printedPages charged. That stays bounded, because a larger font is clipped. A sheet whose rows print more pages than the cells left pay for keeps the rows that fit them and counts sheets-cut, and one left no page is left out the same way. Every sheet that draws prints at least a page of its own, and WeasyPrint's time grows faster than the pages: one-cell sheets printed in 6.4 s for 1,000, 18.8 s for 2,000 and 47.9 s for 3,000, and 4,000 reached the 60 s render timeout, so a workbook of 10,000 tiny sheets printed nothing. The HTML download has no pages to pay for, so it charges a sheet only its cells. A row whose every column is hidden still costs one cell, a sheet whose every row is hidden costs its columns once, and the PDF's page fits the sheets as rendered. Each cut sheet counts a sheets-cut warning. A sheet's images and charts spend the cap after its cells, in paint order (chargeFloating). A chart costs CHART_CELLS (500, what its axes, title and legend cost the render) and the points it draws (sheetChartPoints), so a peer's thousands of charts cost what the cap holds. An image costs its tag and source at CELL_BYTES (50, the HTML a cell with a value writes), because every <img> repeats its whole data: URI: 1,000 entries over one 1 MB picture wrote a 1 GB document. The tag and its style rule count as IMAGE_TAG_BYTES (200), since 80,000 one-pixel pictures that paid for their sources alone wrote 22 MB; the cap now keeps 35,696 of them, 11 MB that WeasyPrint prints in 21 s. Pictures share the cap with cells: it holds about 12 MB of them instead of cells. WeasyPrint also decodes a PNG whole and writes its pixels into the PDF again, and a picture's bytes say little of what that costs: 2560 by 1440 PNGs of real photos took it 0.78 s of CPU each, 210 ns a pixel. So the PDF counts each PNG's pixels from the width and height the main thread sent with it, the picture's as sent (decodedPixels, export/sheets/transform.ts), and the first tag drawn of a picture also costs its pixels at PIXELS_PER_CELL (800). Such a picture then costs 4,608 cells beside those of its bytes. The rate was set on WebP previews of real photos, 0.58 s each, so that the 32 the cap held took about the 19 s its 2,000 pages take. A JPEG costs its bytes alone, because WeasyPrint embeds it undecoded: a full cap of the JPEGs with the most pixels for their bytes, 241 flat 2560 px squares of 39 KB, printed in 1.3 s of CPU and 129 MB. Later tags of the same picture cost only their bytes, since WeasyPrint decodes a source once however many tags show it. An SVG costs its bytes too, as WeasyPrint draws it as vectors. The HTML download charges no pixels (NO_PIXELS), as the browser that opens it decodes its pictures, as it does a preview's. A chart left out is counted as charts-dropped, an image as images-dropped.
Before a sheet renders, windowedSheet (export/sheets/window.ts) cuts it to the window, for the export and the preview alike, so no walk costs more than the window or the keys stored. It places the window from the keys clamped to Excel's grid on both sides, and reads a track map by walking the span only when the span is shorter than the map's keys, else by its keys, so a first cell at row 65,536 costs what the maps hold, not the rows before it. A sheet the cap left no cell reads no map at all. It drops every cell, border key and merge outside the window, and reads a track size that is not a number, shorter than a pixel or longer than a PDF page as the default, as the editor and the import write whole pixels. It clips each rule's ranges to the window, so a range a peer stored past it costs nothing. The evaluator still visits the hidden rows inside the window, as the editor does, so a color scale, a data bar and a top-N take their extremes over every cell of the range the window holds, shown or not. A rule whose shape the evaluator can't read, such as one a peer stored without ranges, with a range that has no rows, or without its values or looks, is dropped and the export goes on. The xlsx writer drops a rule by the same check (isWellFormedCfRule, beside withCfRanges in packages/sheet/src/engine/conditional-format.ts). A merge that meets the window spans only the part of it the window holds, and one that overlaps an earlier one in reading order is dropped, as Excel forbids overlapping merges. It keeps its stored extent, so a side the window cuts takes no border: a 60-column merge whose cells all store a right border previews at the 50-column budget open on the right, as the editor draws none inside a merge. The sweep asks a segment tree whether any column of a merge is covered at its first row, so 100,000 merges as wide as the grid take 22 ms where a scan of their columns took 9 s. A merge spans the rows and columns it covers that show, so a hidden column inside it takes no span and the table matches its colgroup, and its content draws on its first shown cell. Its outer borders fold from the stored keys in reading order, as the editor folds them, and draw on that first shown cell, so a box around a merge whose edge row or column is hidden keeps all four sides. The fold costs the border keys, never the cells a merge covers. A blank sheet draws nothing and takes no page. The preview keeps its own budget, and its images and charts spend 10,000 cells of their own (PREVIEW_FLOATING_CELLS), apart from the window's, so a preview whose window spends all its cells still draws its first charts. On the export's cap a preview of 200 charts drew 99, 22 MB that the byte guard threw away whole; the allowance draws four charts of 2,000 points, about 1 MB of SVG, and counts the rest as left out. A full window of plain cells, each with every look the export writes and a link, is 4.7 MB, so with its charts it stays under the 8 MB byte guard. A window whose every cell is clipped writes 7.8 MB of the same, and its charts can then pass the guard, which truncates the preview. Only the images and charts inside the preview's box spend it, as the box clips the rest away, so 25 charts parked 40,000 px down no longer leave the one at the top undrawn. The box is the budget's 200 rows and 50 columns that show, counted from the window's first track past the hidden ones and past a small sheet's last cell (budgetEnd), so a chart beside a table with hidden columns is drawn.
writeXlsx (export/sheets/xlsx/to-xlsx.ts) writes the SpreadsheetML by hand, the reader's mirror in export/sheets/xlsx/: package.ts names the sheets and writes the workbook, worksheet.ts a sheet part around its grid, cells.ts the grid, strings.ts the shared strings and styles.ts the looks, each look and string interned once. Every part is declared before any is written. A sheet's rows reach the zip writer in batches of about 1 MB, deflated a batch at a time, so a sheet of millions of cells is never one string, and the shared strings and styles the sheets filled are written after them. A cell of a merge writes its own value, formula and stored borders, as Excel keeps them, so a formula reading a cell under a merge computes the same after a round trip. A link is an element of its own beside its cell's value: a web address passes resolveWebLink, the editor's gate, and is an external relationship, and a place in the workbook is a location, a whole range kept, labeled by the cell's text in display. Past Excel's limits, a text of 32,767 characters, a formula of 8,192, a number format of 255, 64,000 cell formats, 65,530 links a sheet or a cell past its grid, the writer cuts and counts a warning rather than failing the export. A sheet name Excel refuses (History, a forbidden character, a quote at an end, a clash in any case after the 31-character cut) is repaired and numbered as Excel numbers a copy, counted too; formulas naming it keep the name they had. A number format is written in Excel's case, m for a month. The bytes hold no timestamp, so one workbook always exports the same file. Inter is the workbook's default font (TYPOGRAPHY.md). A column width is written in 256ths of Inter's digit, and each sheet's default column width and row height go into its sheetFormatPr, so Excel opens a column or row at the size Eigen draws it and the import reads back every pixel. A sheet's charts are its drawing part (drawing.ts), each a chart Excel can edit (CHARTS.md), and its conditional formats and validations its rules' slot (rules.ts). An imported chart sheet is a sheet without cells, so it exports as a worksheet holding its chart at A1. A pie is written without axes, as Excel's pie has none, so the axis settings its spec keeps for a change of type come back as the defaults. A pie's slice colors go with the points its cells resolve to, so a pie over a whole column, an area or more points than a chart draws writes none. A sheet's images go into the same drawing part as pictures, in one paint order with the charts (§ An xlsx picture is anchored as Excel places a turned one). The class-styled HTML and the round-trip drifts: SHEETS.md. A font size is held to Excel's 1 to 409 points. A conditional format a peer forged, with no range in Excel's grid or a shape isWellFormedCfRule refuses, is dropped and counted as conditional-formats-dropped, and a range past the grid is cut to it. Where validation rules overlap, each one loses the cells a rule with a higher id holds before it is written (withoutOverlaps), so no two sqrefs overlap and the import, which numbers rules in the file's order, gives every cell the rule the editor showed.
bun apps/api/src/scripts/xlsx-audit.ts roundtrip <corpus dir...> --out <dir> imports each file, writes it with writeXlsx, its pictures prepared as an export prepares them, imports the result again and compares the two imports key by key, a picture's media by its type and pixel size. Each file runs in a child process of its own under a time and memory cap. A difference the writer explains is listed in LISTED, each kind with its reason, as a sheet name Excel refuses is repaired; any other is unlisted. The run passes with no crash, no cut and no unlisted difference. A file the first import refuses is skipped, so a reader that newly refuses one is caught by compare of two corpus runs, not here.
A deck's HTML and PDF and a drawing's PDF are pages of compositor layers (export/canvas/render.ts), which the canvas previews share. It restates no geometry: layerBoxCss, layerPrintCss, layerInnerHtml and backgroundCss come from packages/lib, and each screen layer gets the box element-layer.tsx gives it live, each printed layer the same box laid out where it prints. So what a user sees is what prints. A screen page holds the scene at 1:1 and scales it once, because a layer's body is authored in scene pixels. A non-text layer sits in an overflow="visible" <svg>, because roughjs overshoots its box.
canvasHtmlDocument (export/canvas/transform.ts) wraps all three, with one @page rule, font block and reset, so they cannot drift. It embeds canvas-text.css for the list and link rules an inline style cannot reach.
Before layout, sanitizeSceneHtml filters every text box to LIGHT_EDITOR_TAGS (packages/lib/src/core/html.ts), the set the canvas mounts a body with. A <table> or <style> a peer wrote would otherwise be invisible live yet print. The document cannot forbid <style> instead, because it carries its own @font-face block.
A frame is the page, at the deck's slide size and physical width (SLIDES.md), and an overhanging element is clipped as the canvas clips it. Every frame is 1920 wide, so a new deck's 960 pt page prints at 2/3, 338.67 × 190.5 mm for 16:9 as PowerPoint's widescreen, and a 10 in deck at 0.5: 960 by 540 px for 16:9 and 960 by 720 px for 4:3, which is 254 × 142.875 mm and 254 × 190.5 mm, Google's PDF sizes (the scale renderEigenslidesHtml takes from the deck's width, export/canvas/transform.ts). The PDF document lays each slide out at 1:1 on a 1920 px @page, and htmlToPdf hands WeasyPrint that scale as its zoom, which prints it at the deck's width. Only the transform process reads the deck, so the export's result carries the zoom back to the main thread beside the document's bytes. The HTML download wraps each page in a .page-fit box that scales it to the viewport, so a deck reads on a phone, with a @media ladder first for a browser that cannot divide 100cqw by a length. The PDF has no fit box, because WeasyPrint has no viewport. The .page-fit rules mirror packages/ui/src/styles/globals.css, since a standalone file cannot import it.
writePptx (export/slides/pptx/to-pptx.ts) writes the PresentationML by hand, from package.ts (the presentation, its master, layout and slides), shapes.ts, lines.ts, pictures.ts and text.ts, the way the docx is written from the JSON: a converter fed the export HTML would keep the text and lose the canvas's geometry. The pptx is written for PowerPoint and Google Slides, which a probe of contained, covered and rounded contained pictures and of text boxes confirmed on 2 November 2026. LibreOffice only has to open it without anything absurd, so a rounded contained picture that LibreOffice stretches is accepted. One frame is one slide, and p:sldSz is the deck's own width and its slide size's height at the deck's pt per px, so a 10 in deck writes Google's 9,144,000 by 5,143,500 EMU and a 13.33 in one PowerPoint's 12,192,000 by 6,858,000. A 10 in deck names its on-screen show type, screen16x9 or screen4x3; any other width is the attribute's default, custom. A text size is the deck's pt to the hundredth (sz), so the 44 pt title a file brought in leaves as sz="4400". Each element becomes the shape that draws it: a preset for a rectangle, ellipse, diamond or pie, a connector for a two-point line or arrow, a line's with the fill it holds, which the canvas paints only once the line loops and the preset's empty area never paints, a custom path for a path element's own commands, for more points and for a freehand stroke's outline, a picture in its element's box with its border as its own a:ln, as PowerPoint writes one, so it reads back on the picture (cover crops the picture's source with a:srcRect, and contain crops outward past it with a negative a:srcRect on the short axis, so a fitted picture is one element both ways and LibreOffice letterboxes it, except a rounded contained one, which it stretches; a background keeps a:fillRect insets, which LibreOffice reads there), a text body, with its runs, lists and links, for a shape that holds text, in the same p:sp as its geometry, as PowerPoint writes it (a shape with no text writes no text body; a pie that draws nothing still writes its text), and a graphic frame showing its own chart part for a chart. A text box, a rectangle with autoFit (CANVAS.md), is named Text and writes txBox="1". A top-aligned one writes a body that grows the shape to its text (a:spAutoFit), an empty one too, so PowerPoint and LibreOffice grow it as the canvas does. A centered or bottom-aligned one writes a:noAutofit at its height, because PowerPoint shrinks a growing shape to its text on the next edit and that would move a bottom-aligned title up; its txBox mark still brings it back as a text box. Every other body writes a:noAutofit, so a shape keeps its size and its text spills. An arrow's label writes txBox="1" with a:noAutofit. A fitted picture that has its box's aspect, to the inset's rounding, writes no crop and reads back as fill, which draws the same. One more than 2,001 times wider or taller than its box is fitted as if it were 2,001 times, so its inset stays inside ST_Percentage's 32-bit int. Every shape writes its own colors and fonts, so the theme only has to be valid. A gradient writes the nine OKLab samples the canvas paints from (gradientStops, packages/lib/src/background/gradient.ts), so a reader that blends in sRGB still draws Eigen's ramp. A link keeps its text's color, as canvas-text.css draws it.
renderEigenslidesExport (export/slides/transform.ts) runs sanitizeSceneHtml before it writes a pptx, the filter the HTML export runs (§ Canvas pages). It is what keeps a javascript: link a peer wrote out of the file, because a pptx hyperlink is a relationship target the writer copies as it is. The pictures come from the media prep (§ A JPEG or PNG from the source). apps/api/src/test/export/slides/pptx/to-pptx.test.ts pins the writer, and slides-export.test.ts a whole export.
DrawingML has no sketch, no hatch and no paragraph border, so a few looks change on the way out, the ones a user would miss counted (§ Every Office writer counts what it leaves out):
- A hatched or rough fill is solid, and a rough outline is the plain one.
- A quote keeps its indent and its fade, but not its bar.
- An elbow arrow's rounded bends are square.
- An arrow label hides the shaft under it with a box in the slide's color, where the canvas cuts the shaft out. On a gradient or picture background there is no such color, so the shaft runs under the text, and over another shape the box covers that shape too.
- A pie's gradient spans the whole ellipse's box at its angle, where the canvas paints it across the slice's own box.
- A rounded rectangle is a
roundRect, and PowerPoint insets its text by about 0.29 times the corner radius on each side, so the text wraps narrower than on the canvas. - A chart is PowerPoint's own, a
p:graphicFrameshowing a partwriteChartwrites (CHARTS.md), with its numbers in a workbook of its own beside the part, as PowerPoint writes a chart, so its Edit Data opens them, unverified in desktop PowerPoint (CHARTS.md). A frame turns and fades no chart, so a chart goes upright and opaque, and its sketch, hatch and dash draw plain, all counted. Its text is written at the size the canvas draws it, in the deck's pt. A spec that doesn't parse, or holds range data, writes nothing, as the canvas draws only its placeholder, and counts as a chart left out. - Fonts are written by name (Inter, Source Serif 4, JetBrains Mono, Excalifont) and not embedded, while the docx embeds them (§ The docx embeds the faces). PowerPoint for the web substitutes all four; Google Slides has the first three and substitutes Excalifont. Embedding them is an open CANVAS-TODO row.
An export never fails for what a format can't hold: the writer writes what it can and counts the rest, so a lost chart or picture is said instead of vanishing. The transform makes one lossCounter (document/transform/protocol.ts), the counter the readers count with, passes its lost to writeDocx, writePptx or writeXlsx, and returns its counts as the export's warnings. The runner logs them on the job's line. One kind of loss has one code in all three writers, and the import's code where the meaning is the same: a chart left out is charts-dropped, and a picture the writer can't write, its media missing or unreadable or an external src a file can't fetch, is images-dropped. The other codes are a format's own: Excel's limits in an xlsx (§ A sheet export recalcs), a docx table wider than Word's 63 columns, whose last cells join the 63rd (tables-simplified), and in a pptx a hatched fill drawn solid (fills-simplified), a rough shape or a rounded elbow drawn plain (shapes-simplified) and a quote's bar left out (text-simplified), each once per element. A figure that names no picture loses nothing. A chart whose look holds a sketch, a hatch, a dashed or dotted stroke or a gradient background, which no chart part paints (chartLookSimplified, beside writeChart), draws plain and is charts-simplified, once per chart in all three writers. A slide or sheet chart that its frame would turn or fade counts the same. addOfficeChart (export/office-chart.ts) counts both chart codes for all three writers. The round-trip audits report the writer's counts beside their differences.
A download answers with the file alone, so the export route puts the summary of its warnings in an Eigen-Export-Summary header (EXPORT_SUMMARY_HEADER, packages/lib/src/types/drive.ts), percent-encoded, and sends no such header for a file that lost nothing. lossSummary (document/transform/losses.ts) builds it from the same table an Import… phrases its losses with (§ An Import… answers), its dropped kinds "left out of the file": "1 chart was left out of the file; 2 fills were simplified." A stored edit that doesn't decode (corrupt-blobs-skipped) is said first, as "part of the document couldn't be read and is missing", because the file misses data the user wrote. The summary holds counts and fixed words only, never a sheet or file name, so it is safe in a header. The download hook (useExportDocument) toasts it after it saves the file, and Print (usePrintPdf) after the PDF shows in its tab, as both fetch the same route. The dev client is cross-origin, so CORS exposes the header beside Content-Disposition (app.ts), which app.test.ts pins. A header rather than a second request or an SSE event, because the summary belongs to this one response and needs no new route or state. A plain link to the route would not see it; no download uses one.
A drawing's SVG is written in the open editor, not by the export route, because only a browser lays rich text out as the canvas does. The server has no layout engine, and the <foreignObject> the canvas's HTML would ride in is drawn by browsers alone, not by Illustrator, Inkscape, Figma, Office or Preview. So Vector's File › Download › SVG image runs downloadCanvasImage (packages/ui/src/components/vector/canvas-image.ts), and the route answers 400 to svg: Drive's Download and ⌘K offer a drawing as PDF only.
The same writer draws Download image, a row of every object menu that holds a chart or a canvas selection (LAYOUT.md): a chart in Docs, Sheets, Slides or Vector, and any selection in Slides or Vector. A single image's menu offers only Download original image, since a picture of a picture adds nothing. Viewers get the row too, because a download writes nothing.
canvasImageScene (packages/lib/src/vector/canvas-image.ts) is the one place the writer's input becomes the elements the image draws, and the background they draw on. A canvas passes its own elements as they are. They are the editor's own drawing, not clipboard input, so the 10,000-element cap a paste puts on a forged wire does not apply. A doc or a sheet has no elements, so it passes the chart item its copy writes, with the look and, from a sheet, the ranges read into inline data; that item becomes the one chart element a canvas paste would make of it, through the element reader. The whole drawing keeps its own background, a selection is transparent and a chart alone draws on white, as it does in a doc or on a sheet. A chart's file is named for its title (chartImageName), less the characters a file name can't hold, or "Chart"; a selection is " – selection". sceneToSvg draws the elements. Images are the canvas's own media, the screen preview, inlined as data: URIs.
Each element's text, a text box's or a shape's, is laid out again in a hidden copy of its layer, its own markup and style at its size under the app's canvas-text.css. Once document.fonts.ready resolves, Range.getClientRects reads where every character landed, and richTextSvg writes one positioned <tspan> per run of characters side by side on one baseline, each word on its own on a justified line, since no SVG reader stretches spaces. A list's number is text the copy carries where the browser draws its ::marker. A disc is not: Chrome paints it as a circle from the font's rounded ascent (Blink's symbol-marker rect), which no DOM API reports, so richTextSvg restates that geometry; another browser's canvas draws its own disc, which the file does not follow. A quote's rule is a <rect>. In Chrome the file draws pixel for pixel what the canvas draws, for plain, mixed-style, linked, listed, centred, justified and quoted text in all four bundled faces; a rotated box differs only in antialiasing, under 0.5% of its ink.
The file embeds only the slices its text touches (canvasImageSlices, through slicesFor): a box's measured runs, each in the family its computed list starts with, and every other element's text, upright, each also in the emoji and CJK fallbacks. The slice table is large, so the download loads it on demand. Italic is a face of its own, so a drawing with no italic run carries no italic slice; a family without one, Excalifont or JetBrains Mono, slants its upright slices. Every bundled face is variable across its weights, so a weight never picks another file.
Right-to-left text is an accepted limit: a run is written in logical order at its measured start. An emoji or a CJK character draws in its fallback slice only where the run's family list names the fallback family.
A drawing's PDF is a single compositor page sized to the content plus 10 px on each side, the margin sceneToSvg leaves for roughjs's overshoot. Rich text prints because it is an HTML div; WeasyPrint ignores <foreignObject>. A transparent drawing prints on white paper, because WeasyPrint has no canvas behind the page. The page is capped at 19,200 px a side (MAX_PDF_PAGE_PX, export/weasyprint.ts), the PDF's 200-inch limit, so a far-out element cannot make WeasyPrint lay out an unbounded sheet. Artwork past it is cut off. The page is sized from the element boxes, so text that spills out of a shape past the drawing's outermost element is cut off in the PDF, the preview and the SVG download. Centered text that overflows a shape spills downward only in the PDF, and wraps a little wider than on the canvas, because WeasyPrint does not center an overflowing flex item. A sheet's PDF page has the same cap, and a taller sheet continues on the next page.
A gradient (fill="url(#…)") or an image clip (clip-path="url(#…)") stays an SVG attribute pointing at the element's own <defs>. WeasyPrint resolves url(#id) only within the same <svg>.
No transform sits above a PDF layer, and a layer's only transform is its rotation: renderPrintPage lays each layer out where it prints, its box minus the page's origin as top and left (layerPrintCss, beside the live canvas's layerBoxCss), and a deck scales through WeasyPrint's zoom, never a CSS scale. WeasyPrint 68 lays a page out ignoring transforms and cuts every line whose layout box passes the @page height, and it clips a translucent element to the page as its parent's transform maps it. A layer laid out at the scene origin and moved by a translate would lose a text box's lines wherever its layout box passed the page bottom: in a slide's lower half under a scale of 0.5, in a text box starting above a slide, in a drawing drawn above or left of the scene origin (canvas-pdf.test.ts).
An arrow's shaft is hidden under its label by a <mask>, because WeasyPrint ignores clip-rule="evenodd". WeasyPrint applies a mask after drawing a node's children, so the reference goes on each shaft <path>, never a wrapping <g>. See labelMask and maskShaft in packages/lib/src/vector/kinds/arrow-render.ts.
importIntoDocument imports into an open document (/import, /import-from-drive) and convertToDocument (/convert) creates one beside the source, both in import/import-document.ts. The transform converts bytes only and never sees an owner, mount or path. So a failed transform creates no document and mutates no Yjs state, and its 400 or 413 crosses the boundary unchanged. An xlsx comes back as snapshot JSON plus images, the JSON committed without parsing on a replace and decoded into new tabs on an insert (SHEETS.md); a docx as a ready Yjs update, its page and images; a pptx as a ready Yjs update plus images. A convert refuses a file on its extension before it reads it, so a .pptm or a .potx never reaches the reader. It then reads the stored file under the upload limit, as Import from Drive does, since the row's size is only a claim: a file that grew past the limit is refused once the read passes it, not buffered.
The route checks write before it buffers, but the job can queue for minutes, long enough for a share to be revoked. So importIntoDocument checks write again after its version and its pictures, and a revoked writer gets a 403. Only then does it open the document, right before the synchronous Yjs write, so it never writes into a document that closed meanwhile (COLLAB.md). A revoke that lands while the document opens gets through. convertToDocument checks write on the destination folder before it reads the source, so a reader pays for no transform, and needs no recheck, because SharedDrive.create checks write. A convert that fails after its create (quota another write spent) trashes the new document through Drive.deletePath and answers the failure; the trashed document sits in the owner's Trash, counted against quota, until it is purged (SOFT-DELETE.md). It takes no abort signal either: a page reload must not kill a minute of conversion, and the file appears through the drive SSE refresh.
Every reader counts what it leaves out or draws simpler as it reads, through one lossCounter (document/transform/protocol.ts), and returns the counts as warnings beside what it read. The runner logs them on the job's line. An Import… of any format answers with lossSummary (document/transform/losses.ts) of them, which the hook toasts: "2 tables and 1 SmartArt graphic were not imported; 5 shapes were simplified." One kind of loss has one code whichever format met it, a docx's SmartArt and a deck's without its drawing both diagrams-dropped, and one noun in the table that phrases them, so it reads alike in every format's toast and in an export's (§ An export says what it left out). A warning without a count, a failed recalc, says nothing. A Convert, of any format, answers only the new document's path and opens it without a summary: a deck and a doc show what is missing themselves, a deck in its placeholder boxes and a doc in its "Picture not imported" and chart lines. A workbook shows nothing in its cells, so a Convert of one leaves its gaps unsaid.
The three readers leave a picture out by one rule, through importMedia (import/image-types.ts): a part that isn't there, one of a type the format doesn't keep, one whose bytes don't read, and one past MAX_MEDIA_PARTS (1,000) pictures or past the upload cap in bytes, which the job carries as maxMediaBytes, is left out and counted as images-dropped, never a refusal of the file. A slide draws a "Picture not imported" box in its place and a doc a "Picture not imported" line after its paragraph, aligned as it is, both PICTURE_NOT_IMPORTED. A linked picture, or one whose r:embed names an external relationship, lives outside the file, so it is left out and counted too, a doc keeping its alt text after the line, a VML picture's as a DrawingML one's. A picture whose svgBlip part can't be shown, missing or of another type, comes in as its raster in all three readers. One whose r:embed names no relationship at all is left out and counted the same way. Each reader finds a picture's blipFill through the branch of an mc:AlternateContent its format understands (alternativeFor, ooxml/ooxml.ts): the docx reader's descendants, and ooxmlChild in the pptx and xlsx readers.
An import into an open document takes a mode, insert or replace (DocumentImportOptions), in the upload's query, since its body is the file, and in the Drive pick's body beside the source. A docx only replaces, and an insert into a doc is a 400 before the transform: the file's page setup, Normal style and bands can't sit beside the doc's. Before a replace writes, importIntoDocument calls saveVersion, as a version restore does before its own write. A replace clears what the document held, its comments' anchors and a peer's pending edits included, and nothing else can bring that back: Ctrl+Z doesn't undo a server's write, and the newest automatic version can be up to 99 writes old. So the document as it was is one restore away in its version history. An insert saves none, because it writes only new records. A deck replace with a file that has no slides says so and saves nothing. The collab document is opened last, right before the write, because a compaction can close it during any earlier await. So a document trashed during the transform still gets the version, and maybe the pictures, before the import fails with a 404. Both are harmless leftovers in a document that is in the Trash.
The version holds the container's data.db, not its media folder. A docx, pptx or xlsx import names its pictures with a random prefix per import (xlsx-<uuid>-image-1.png) and writes them beside the media already there, so a restored version draws its own pictures.
The upload bound limits compressed bytes, and an inflated package can hold 200 MB of XML. So the docx, xlsx and pptx readers open the package with openZip (ooxml/zip.ts), which refuses a declared total past the byte cap before it inflates anything and inflates an entry no further than it declares, so a forged directory that declares a small size is refused too. Nothing else reads the upload. A file a reader can't read is a 400 in its format's words, and an encrypted package names the application that removes its password (importError and the words in import/messages.ts).
The xlsx reader (import/sheets/) reads the SpreadsheetML itself. Bun has no streaming XML, and a parsed tree costs about 100 times its text, so a sheet's grid, which can be the whole byte cap, is never one tree. readInBatches (import/sheets/package.ts) cuts <sheetData> into batches of whole rows of about 1 MB, each parsed behind the part's prolog and start tags, so prefixes bind and a DOCTYPE is still refused. The cut is checked, not trusted: a batch that won't parse, because a comment, CDATA or an instruction hid the cut, reads the part whole past the rows already read, under the budget below, and a part that won't parse whole is malformed XML, never a misread. The shared strings are read in batches too. The workbook, the styles, the theme and a sheet's part without its grid are each parsed whole, once.
What the reader parses is charged before it is parsed, against the read budget in import/sheets/package.ts, because a node costs its parse and its memory whatever it emits, and a 1 MB upload inflates to tens of millions of empty cells or strings. Every grid's and the shared strings' elements are counted in their bytes before the part's first batch, against MAX_XLSX_ELEMENTS together (16 million, a little over twice the corpus's most, 7.3 million, so the cheapest flood parses in seconds), the prolog's and start tags' elements again for every batch they are parsed in front of, and every reference of a validation's or a conditional format's sqref before it is parsed. A batch past MAX_BATCH_BYTES (12 MB) is refused: a batch ends at the first row or string past 1 MB, so it holds one long row or string whole, and the corpus's longest is a 2.5 MB row. A sheet's part past its grid, and a part read whole because its cut can't be checked, never passes through openPackage's part cache, so it is charged its bytes and tags against a budget of its own with the package budget's values, MAX_PACKAGE_XML_BYTES and MAX_PACKAGE_XML_TAGS (32 MB and 1.5 million, together across the sheets). The workbook, styles, theme and relationships parts go through the part cache and its budget. Past any of these the import is a 413 "This spreadsheet is too large to import".
What the reader emits is charged against one budget as it reads, before it is built, so a range a file declares costs what it emits, never what it spans. MAX_CELLS counts every cell emitted: a value or a border, a rich string's run, a row's height or hidden flag, each cell of a merge, charged from its area before any is visited, a linked cell, a column's width, a conditional format's or a validation's ranges, each range one cell for each rule that holds it, however many cells it spans. Each sheet's span, its rows by its columns and at least a default grid's, counts against the same cap, because recalc walks it. The grid recalc builds, at least 100 rows deep and 26 columns wide, has a budget of its own, MAX_GRID_SLOTS: a sheet holding A1 and XFD1 spans 16,384 cells but builds 1.6 million slots. A row or column past the grid's last, a cell past the cap, or a span past it is a 413 "Spreadsheet has too many cells"; any other cap a 413 "This spreadsheet is too large to import".
MAX_TEXT counts the text the reader writes: every value, display, rich-text run, number format, formula, link and rule text. A shared string counts once for every cell that shows it, because the snapshot spells it out per cell, so a 1 MB string in a thousand cells is a gigabyte. A shared formula's copies are translated before they are weighed, and a formula that uses a defined name is walked before it is inlined, so both have a budget of their own, MAX_SHARED_FORMULA_TEXT. The caps sit in import/sheets/limits.ts. An insert into an open workbook weighs the file with the workbook against MAX_CELLS and MAX_TEXT again, counting only the cells and their own text (SHEETS.md).
Only what the file holds is walked, never the row or column numbers before it, and merges are never checked against each other: a merge over a cell an earlier one holds is dropped, as Excel repairs it, found through the cells already placed. A number format past Excel's 255 characters reads as General, and one numfmt can't parse keeps its code but displays General, tested once: numfmt parses it again on every display to throw. Cell formats that name more than 1,000 number formats, four times Excel's own allowance, are refused, since each costs its date test and canonical form. A shared formula past Excel's 8,192 characters is not translated into its copies, which keep their values: a translation costs about a hundred times its text.
Over 1,062 public workbooks (GOV.UK statistics, Zenodo data, and the test files of Apache POI, LibreOffice and other readers), the caps refuse 14. POI's 49609 is refused as unreadable, because its part names use backslashes, which openZip keeps verbatim. apps/api/src/test/import/sheets/limits.test.ts pins the bounds on small hostile files, one per class.
The docx reader opens its package with openPackage (apps/api/src/lib/ooxml/opc.ts), so it reads only through openZip too. It parses only the parts it needs (the content types, the document, its styles, numbering, theme, font table and notes, the final section's headers and footers, the settings when an even page's band is named, their relationships, a chart's part reached only by a chart relationship under word/charts/ and of the chart content type, and SmartArt's own parts), never webSettings.xml or comments.xml, each once. A parsed XML tree costs many times its text (apps/api/src/lib/core/xml.ts gives the measured ratios) and per element, so 16 MB of empty paragraphs is 2.8 million elements and 3.5 GB, while the largest real document met holds 611,000 tags. So openPackage charges every part the reader parses, a chart's or SmartArt's included, against the package's parse budget before it inflates and before it parses (DOCUMENT-TRANSFORMS.md). A part past it makes the whole import 413 "This document is too large to import", and the transform process's memory limit is the backstop past the budget. The budget's worst case is dear: 1.48 million empty paragraphs, just under its tags, peak at 2.2 GB in the reader before the output's weight refuses them. What the reader builds has caps of its own: MAX_DOCX_PIECES for the text pieces a run splits into past its first, about 600 bytes each before the output is weighed, so 4 million of them from 5.7 MB of XML cost 2.5 GB; MAX_INLINE_NODES per paragraph and the output's weight and text (below). The structural bounds (columns, table and quote nesting, list starts, style chains) sit beside the code they bound in apps/api/src/lib/import/doc/; a thousand nested tables overflowed the reader's stack before the nesting cap. Past a cap the import is a 413 "This document is too large to import", and the import maps a stack overflow, in the reader or the Yjs conversion, to the same 413, as the transform process answers an import that runs out of memory (DOCUMENT-TRANSFORMS.md); any other RangeError, as ProseMirror's schema and table checks throw, is a reader slip, logged and 400. apps/api/src/test/import/doc/limits.test.ts pins the bounds.
The transform runs recalcSheets and encodes the snapshot as computed, so no later read recalcs it. A recalc failure, formula ranges past the server's budget on what they index or read among them (SHEETS.md), stores the parsed values uncomputed with a warning, and the first export recalcs them. The importer keeps these rules:
ct.fais always paired withct.t,'General'when Excel reports no number format. Without anfa, date serials and percents render as raw numbers.- A formula cell's
fkeeps its leading=(=SUM(A1:A3)), the form a sheet stores; the xlsx writer strips it. A function Excel added after 2007 is stored as_xlfn.IFNA(…); the reader drops the prefix so the engine recomputes it, and the writer adds it back for the functionsEXCEL_FUTURE_FUNCTIONSlists (packages/lib/src/sheets/excel-functions.ts, which holds every Excel function name), since Excel reads a bareIFNAas an unknown name. - An error cell, Excel's
t="e", is an error,ct.t: 'e', as the editor stores one, so a formula reading it sees the error and not text that reads as one; the writer writes it back as one. A string that reads as an error stays a string. - A number keeps no horizontal alignment unless its format names one, and
horizontal="general"is none: the editor and the HTML and PDF export align a number or a date right by its type, a boolean or an error centred, as Excel does (generalAlign). - A merged cell other than the anchor keeps the value the file holds under the merge, and the xlsx writer writes it back, so a formula reading it computes the same. Excel and the editor show the anchor's across the merge, and the editor tells the anchor by its
mc.rs. - A rich string keeps its runs as the editor stores them,
ct: { t: 'inlineStr', s }, each with its font, a run Excel wrote without properties in its cell's. The runs are the text's one source, with novormbeside them, so an edit in the editor leaves nothing stale; every reader of a value or a display, the formula engine, the conditional formats, the HTML export and the search index, reads a rich cell throughrichText(engine/format.ts). The runs are a copy per cell, so each is charged as a cell. - Every imported sheet gets its
defaultColWidthanddefaultRowHeightfromsheetFormatPr, Excel's own defaults when the file has none, so its unsized columns and rows keep Excel's pitch (SHEETS.md). A row at that default height stores no height of its own. The estimated height of a wrapped cell with text, or of a cell or rich-text run whose font is larger than Excel's 11 pt default, grows a row the file leaves at the default height, because a reader refits a row that isn't custom to its content, and LibreOffice writes the defaulthton every row. A row the file markscustomHeightkeeps its height, the default too:customHeighttells a reader the height is the user's and not to refit it. The editor draws a row without a height of its own at the default, so the writer marks such a row custom at the default (htat the default withcustomHeight="1") when it holds a wrapped cell with text, or a cell or rich-text run whose font is larger than Excel's 11 pt default (refitsOnOpen,export/sheets/xlsx/cells.ts; the one constant isDEFAULT_FONT_SIZE_PT,ooxml/spreadsheetml.ts). Without it LibreOffice refits a row with nohtwhen its text runs over more than one line or its font is above about 11 pt: a long wrapped cell opens at 303.7 pt, a 20 pt cell at 24.5 pt and a 24 pt rich run at 29.1 pt, while a one-line wrapped cell, an empty wrapped cell and an 11 pt cell stay at 15 pt. Our own reader would grow the row on the round trip too. - A hidden sheet is the editor's
hide: 1, and so is a very hidden one, which the editor can show again, and a workbook that hides every sheet shows its first, because Excel always shows one and the editor opens on a sheet it shows. A tab colour is the sheet'scolor. - A color's
tint, a theme color's or an rgb one's, shifts its HLS luminance, toward black by a negative share and toward white by a positive one, as ECMA-376 and Excel do (resolveColor,import/sheets/styles.ts). A blend of the RGB channels agrees with it only when it lightens a light color or darkens a dark one, and imports Text 2, Lighter 40% as#8F99A6where Excel draws#8497B0. Excel's own swatches work in whole steps of 1/240, so a channel can sit up to two steps from the spec's value;apps/api/src/test/import/sheets/styles.test.tspins that.
The engine has no defined names: _callVariable (engine/parser/parser.ts) reads only TRUE, FALSE and NULL, and any other name is #NAME?. The cached value hides that at import, because recalc keeps a good cached value over an engine error, so =SUM(Sales) shows Excel's total until an edit upstream recomputes it to #NAME? for good. So the reader replaces each name with what it refers to as it reads each formula: =SUM(Sales) is stored as =SUM(Data!$A$1:$A$9). The same happens to a conditional format's formula and a validation's formulas, so a list from a name keeps its rule as a list over the name's range. A chart ref that is a name still falls back to the chart's caches.
readXlsxPackage reads the workbook's <definedNames>, skipping Excel's own _xlnm.* (print areas, filters) and _xlchart.*. nameInliner (import/sheets/names.ts) resolves each name through inlineNames (engine/formula-shift.ts), which walks the formula with the walker that shifts a shared formula's copies, so a string, a structured reference and a function of the same name are left alone. Names compare without case, as Excel's do. A name local to a sheet wins over the workbook's on that sheet, and Sheet!Name reads that sheet's local name only. A name that names another resolves through it, up to MAX_NAME_DEPTH deep, which also ends a circle. A range stands as written, a number or a string as itself, any other formula in parentheses so its operators keep their order, and a name over a deleted range is #REF!, as Excel shows it. A shared formula is inlined at its anchor, so its copies shift the inlined text and the name's absolute refs stay put.
Some names can't be written into a formula as text: one into another workbook ([1]Prices!$A$1), one over several areas, one relative to the cell that uses it (its refs have no $, so it means a different cell in each formula), and one whose formula would pass Excel's 8,192 characters. Each stays as written, so its formula is #NAME? after an edit, though the server's recalc keeps Excel's cached value for it, even inside IFERROR (SHEETS.md). It counts once toward names-dropped however many formulas use it. A formula with no name is kept as written, not as the walker writes it back, which drops the spaces around a range's colon. It is not walked at all: one scan splits the formula into tokens where the walker ends a name, and looks each up, lower-cased, in a set of the names' spellings, so a defined name costs a formula that doesn't use it one scan, however many names the workbook holds. A word in a string or a sheet's name is a token too, and costs only a walk that inlines nothing. One regex alternating every name's spelling did this before: past about 400 KB of pattern, 20,000 names of 30 characters, it took 27 s a formula, and 40,000 names were too many to compile, so the workbook was refused. The walk is charged to the shared formulas' budget, so 20,000 formulas of 6,000 characters that each use a name are refused as too large instead of costing half a minute. A name's own text loses Excel's _xlfn. prefixes as a cell's formula does.
The cost is that the names are gone from the doc: an Excel user sees Data!$A$1:$A$9 where they wrote Sales, and the xlsx export writes no <definedNames>. Real names, stored on the workbook and resolved by the engine, are a SHEETS-TODO.md row. Over the corpus's 56 readable workbooks that define a name, inlining takes the #NAME? cells after a full recalc from 40,771 to 38,397, the rest coming from other causes such as functions the engine lacks, and keeps 52 validation rules that were dropped.
A chart Excel anchors on a worksheet comes in as a sheet chart (SHEETS.md) through readChart (ooxml/ooxml-chart.ts), a column, bar, line or pie chart, a 3-D one drawn flat, in the theme's colours. placeDrawings (import/sheets/drawing.ts) reads the sheet's drawing part once every sheet has its id, because a chart's refs may name any sheet. A ref names its sheet by the name the workbook writes, lower-cased as Excel compares names, the first sheet of a name winning, and becomes a ref by that sheet's id. Ranges come first, as Google Sheets saves a chart's caches empty. A ref ranges can't hold, a literal, a defined name, several areas, an area, or a sheet the workbook doesn't have, as an external workbook's, brings the whole chart in from its caches as inline data, which shows the file's numbers and follows no cell. A pie over more cells than a chart draws slices for reads its caches, as a ref it can't follow does, and is left out and counted only when they hold too many points too.
A two-cell anchor spans the same cells as in Excel, between its corners whichever comes first: each corner is its cell's place, walked through the sheet's own widths and heights with an unsized track at the sheet's default, plus its offset into the cell. The walk is sheetColumnsWidth and sheetRowsHeight (packages/lib/src/sheets/defaults.ts), the one the HTML and PDF export draw the overlay with, so an export puts the chart over the same cells. A one-cell anchor takes its size from its xdr:ext, and an absolute one its place too. The box is stored through sheetFloatingBox, the clamp every sheet writer uses, at angle 0, since the reader takes no rotation from the anchor. An mc:AlternateContent reads the branch in the drawing's vocabulary, so Excel 2016's charts (waterfall, funnel), whose choice is a cx:chart, fall back to their shape. Shapes in the drawing are not read; pictures are (§ An xlsx picture comes in as a sheet image).
A chart sheet is a sheet of its own, in its place among the tabs, with its name, hidden state and tab colour and no cells: the reader reads it as a worksheet without a grid. Its chart sits at the top left at its anchor's xdr:ext, or 977 × 638 px, Excel's chart sheet on its default page, when the anchor names no size. A chart sheet whose chart can't come in is an empty tab, so a workbook of chart sheets only still imports.
A chart part is read once however many anchors name it. Every chart placed is charged its part's chartCost and the tracks walked to place it against MAX_XLSX_ELEMENTS, since readChart builds every point a cache declares, and its spec's text against MAX_TEXT. Charts are placed after every grid is read, so a chart past the element budget is left out rather than refusing the workbook. A chart left out, one the reader can't draw, a part missing or damaged, one past the budget, or an Excel 2016 chart, counts toward a charts-dropped warning, a conditional format the engine has no rule for, such as an icon set, or one over no range of the grid, toward conditional-formats-dropped, and a data validation the engine has no rule for, a custom formula, a list from a name that is no range or a bound that is a reference, or one over more than MAX_VALIDATION_RANGES (8,192) ranges once its sqref is coalesced, toward validations-dropped. Excel's "Any value" validation lets everything in, so it holds no rule to lose and counts nothing.
drawingOf (export/sheets/xlsx/drawing.ts) writes each image as an xdr:pic in a two-cell anchor over the cells its box covers, the anchor a chart takes, so a picture and a chart stack in Excel as in the editor. The picture is the media prep's JPEG or PNG under xl/media/, an SVG beside its PNG as the svgBlip, one file however many sheets show it. A picture whose media the prep has none for, a missing file or a type sharp can't read, is left out and counted as images-dropped. Excel turns a picture, so a:xfrm rot carries the image's angle, clockwise as Eigen turns it, while a chart goes upright.
Excel and LibreOffice place a picture turned within 45° of a quarter turn (45° to 135°, 225° to 315°) by its box turned that quarter: the anchor and the a:xfrm hold the box with its sides swapped about its centre. Written unswapped, LibreOffice squeezes the turned picture into the upright box. anchoredBox (ooxml/drawingml.ts) is that swap, its own inverse, so the writer and the reader share it. A sideways box that would start left of A1 or above row 1 is moved onto the grid, since an anchor can't start before it.
placeDrawings (import/sheets/drawing.ts) reads a worksheet's xdr:pic anchors beside its charts, in file order, so one fractional paint index orders both. A two-cell, one-cell or absolute anchor gives the box as it gives a chart's, an mc:AlternateContent reads its branch in the drawing's vocabulary, and a:xfrm rot the angle, a sideways one turned back from its anchor through anchoredBox. An image's id is a fresh uuid, as the editor's are, so an insert beside the workbook's own images never collides.
The picture's part is its svgBlip's, else its a:blip's, read raw and never parsed. pictureMediaPart and importMedia (import/image-types.ts) are the pptx reader's rules, shared: only PNG, JPEG, GIF, WebP and SVG come in, the types both a browser and sharp draw (PICTURE_TYPES, which lists them, so a type added to the readers later stays out of slides and sheets until it is listed), and each part is one media file named per import, as a docx's is. A picture of another type is left out and counted by the readers' one rule (§ An Import… answers). The anchor's tracks are charged against MAX_XLSX_ELEMENTS as a chart's are. The bytes cross back beside the snapshot, and the main thread writes them through Drive before the snapshot that names them, under the owner's quota, as a pptx import does. A picture in a group, a crop and a flip are not read.
readDocx (apps/api/src/lib/import/doc/from-docx.ts) reads the WordprocessingML straight into a doc of the schema, with no HTML between, in the transform process; docxToPmJson gives the same doc as JSON, for the audit and the tests. An HTML converter between the file and the schema loses most of what Word shows (alignment, color, fonts, list numbers, image widths) and needs a sanitizer after it; reading the XML into the schema keeps the looks and leaves nothing to sanitize. The reader resolves styles through their chain and runs Word's list counters in document order, so a list keeps the numbers Word draws. A property Eigen has no home for (a paragraph's shading) leaves its text and drops its look, and light text on it takes the body color, or it would vanish on Eigen's paper: the gaps are a DOCS-TODO row. Word comments are not read, and their text stays. Tracked changes come in accepted. The reader also recognises the writer's looks (document/looks.ts, which both share), so an export that Google Docs re-saved, which flattens Eigen's styles into direct formatting, still comes back with its quotes, code blocks, captions and done tasks, and its links with no color or underline mark. Heading 5 and 6 draw Google Docs' grey, and 6 its italic, so the reader drops that grey from their runs, as it drops a quote's. Word's Title and Subtitle styles, a name Word, LibreOffice and Google Docs share, become the title and the subtitle, which the writer writes in those built-in styles. The node draws their size, and the subtitle its grey, which the reader drops; a style's bold or italic stays a mark, as the title and subtitle draw neither. An italic mark in Heading 6 draws upright, as in Google Docs and Word, so the writer writes it as <w:i w:val="0"/> and the reader makes a run Word draws upright there the mark, and one Word draws italic plain text. Word's own Heading 6 style is not italic, so its plain runs come in as the mark. A Word page break splits its paragraph on the way in (DOCS.md). Word's math comes in as equations: each m:oMath is an equation node holding writeMathml(readOmml(…)) where it stands among the runs, and each m:oMath of an m:oMathPara a displayed one; an equation past a bound of the package comes in as the text of its m:t, Word's neighbouring runs one word and each object a word of its own, as Word cuts runs by style, and one in a code block or a caption is its linear text there, readStoredEquation's text.
Word's notes come in as footnotes (DOCS.md): each w:footnoteReference becomes a footnote node holding its note's paragraphs, read where the reference stands (readNote, import/doc/paragraphs.ts) through the notes part's own relationships, as a band's are, so a link or an image in a note resolves. An endnote is a footnote too, numbered among them in document order, as Google Docs has no endnotes. The separators and the continuation notice no reference names are no notes, Word's number at the note and the space or tab after it go, and a note's heading, list, table or code comes in as its paragraphs, as the node holds paragraphs only. A note's paragraph draws in its style's look, as a heading does in its own, so Word's FootnoteText, 10 pt and single spaced, gives its runs no size mark and its paragraphs no format. A custom mark (w:customMarkFollows) drops the text it draws, a * say, the run's text up to a tab: Eigen numbers every note, so the reference shows its number. A reference to a note the file lacks keeps its text. A note is read once per depth however often it is referenced, in the body or in another note, and each reference holds a copy, charged past the first as the pieces no tag counts are (pieces, import/doc/package.ts), so 1,000 references in a note to a 1,000-paragraph note are a 413 in milliseconds. A note referenced inside a note is its text there (flattenNestedStories), read at most two deep, so a note's text is the same whichever reference reads it first, and a reference back to a note being read goes. A note's numbered list, and a band's, counts on its own, as Eigen numbers them, so neither continues the body's count.
The JSON is checked against the schema block by block. A block the schema refuses keeps its text and equations as paragraphs, so a reader slip never fails an import.
The doc's page is the body's last w:sectPr, Word's document-level section, its size and margins read through readPageSetup, so a hostile w:pgSz clamps as a peer's write does. A size the section leaves out is the default A4, a margin Word's 1 in, so the page and the anchors placed in its column match what Word draws. A doc has one page, so an earlier section with another size or margins is dropped with a warning. A wrapped picture goes left or right by which half of the column its middle sits in, and Word gives its offset from the area wp:positionH's relativeFrom names: the paper's edge for page and the left or inside margin, the column's end for the right or outside margin, and the column for the margin, the column and a character. A third of the corpus's anchored pictures are page- or margin-relative, and an offset from the paper's edge read from the column's would put a picture in a wide left margin on the right. The offset is kept on the paper first, so a hostile one decides nothing past its edge. A picture in front of or behind the text keeps both its offsets (§ A Word picture in front of or behind the text). w:orient is not read: Word draws the page w by h whatever it says. Images and tables import no wider than that page's text column, a table in a cell no wider than the cell inside the padding the editor draws, in whole px, which the writer's column holds, so a width comes back as it was read. A column the file's grid gives no width takes a cell's own w:tcW over it alone, as Word builds a grid a generator left out. w:tcW w:type="nil" (ECMA's No Width) is Eigen's spelling of a column with no stored width (NO_STORED_WIDTH, ooxml/wordml.ts): the writer gives such a column's cells it and puts the column's share of the text column only in the grid, which lays it out, and the reader reads a column whose single cells all have it as unset. Word's own auto is no such mark, as 36 corpus files hold it over a grid Word laid out.
The header and footer are the final section's: its default parts, and page 1's when the section has w:titlePg, which turns Different first page on. Its w:pgMar w:header and w:footer are the page's distances, read through readPageSetup and so held within the margins, and Word's 1.27 cm where it names none. A reference a section leaves out is the one an earlier section named, as Word inherits it. Each part is charged against the package's parse budget and read as the body is, through its own relationships, so a header's logo resolves. A doc has one header, so even pages' bands, which Word draws only with w:evenAndOddHeaders on, are dropped with a warning. A reference counts only through a header's or a footer's relationship. A band isn't paged, as a note isn't, so a page break in it goes; a space after on its last block goes, as a band's last block draws none; an anchored picture in it is a block figure, as a band holds no float, with a warning; and a node a band's schema leaves out is its text, with the body's flattening warning. What would cover or swamp the page drops, with a warning of its own: a picture anchored behind the text (behindDoc, a full-page letterhead or a background) and a VML picture, Word's picture watermark, which as block figures pushed whole pages of body away, and Word's text watermark, a VML shape whose text is its path. A band part that is missing or damaged is dropped with the same warning. A note reference in a band drops too, as a band's schema holds no note. PAGE, NUMPAGES and SECTIONPAGES fields, complex or simple and whatever their switches, become the page number node, SECTIONPAGES the page count, as a doc is one section, in the body too, and their cached digits go. The bands are weighed with the body, checked block by block as it is, and ride in the update in their own roots, each as its editor leaves it on open.
commitEigendocImport (document/doc.ts) clears the body and the header and footer fragments, applies the update and sets every page key and every key of the Normal in one transaction, so an import replaces rather than appends and leaves no header, page or Normal of the doc before it. The page and style keys are set on the live doc, never in the update: a key the transform's own doc sets is concurrent with the live one, and Yjs keeps whichever client id orders last.
Each image part becomes one media file through importMedia (import/image-types.ts), as a deck's and a workbook's pictures do: named docx-<uuid>-image-1.png, docx-<uuid>-image-2.wmf, … in the order met, the prefix drawn once per import, its extension from the part's content type, its bytes read when it is first named. A docx keeps every image type IMAGE_EXTENSION_BY_MIME (ooxml/drawingml.ts) names, where a slide and a sheet keep only those they draw, so a part declared as anything else never reaches the container. A picture left out follows the readers' one rule (§ An Import… answers). A picture is read where it is first named, in one pass, so one in a block the schema later refuses still spends a slot and its bytes under the caps, and a later picture can be left out for it; such a file is rare enough to accept. WMF, EMF and BMP are kept under their own name though sharp reads none of them (UNSHOWN_IMAGE_TYPES). A browser draws a BMP, but the editor and quick look load every image through its sharp preview (/file/:pathId/preview), so all three show as a broken image with its alt text, which the user can delete, and the HTML, PDF and docx exports, which re-encode through sharp too, leave the picture out and keep its caption. Each figure of one counts toward images-unshown, which the toast says apart from a picture left out ("2 pictures can't be shown"): the picture is in the doc. The images are written through Drive as an upload is, with its write check, drive event and thumbnail, and before the update that names them: an open tab looks each name up once, as the update arrives. The images count toward the mount's quota at their inflated size, which the upload cap bounds: saveImportedImages charges their total through enforceMountQuota before the first is written, a repeat import in full, as it overwrites nothing, and a conversion checks before it creates the document, so a 507 leaves nothing behind. A Mac logo whose mc:AlternateContent choice is a PDF comes in as the PNG of its fallback, as every reader reads a picture through the branch its format understands. A SmartArt graphic keeps its text, one paragraph per shape.
A chart comes in as a docs chart node through readChart (ooxml/ooxml-chart.ts), from the values the file caches, as a pptx's does: a column, bar, line or pie chart, a 3-D one drawn flat, its series in the theme's colours, or the chart's own where its c:clrMapOvr overrides the map. It is placed as a picture is: its wp:extent in px (a chart in a group or a canvas takes its own frame's a:ext, which a resized group scales by its a:ext over its a:chExt, as it scales a picture's), shrunk to the text column (the paper, for one in front of or behind the text) and to CHART_MAX_SIDE keeping its shape, an anchored one wrapped, aligned or placed in front of or behind the text by anchorPlacement, an inline one aligned by its paragraph, and a caption taken by the same rules, since the reader tests for either frame node (isFigureFrameNode). A chart the spec can't draw as Word does is a paragraph in its place with Slides' words for it, its kind and its title (droppedChart: "Scatter chart not imported"), and so is a missing or damaged part, as "Chart not imported". A chart past the spec's caps on points or series is a kind Eigen draws, so it leaves the plain "Chart not imported" too, in every format, rather than a kind name that reads as one Eigen can't draw. A part is read once however many drawings name it, and its elements and the points its caches declare (chartCost) are charged once against MAX_DOCX_CHART_WORK (import/doc/package.ts), because readChart builds every point a cache declares before the spec refuses them; a chart past it is the plain "Chart not imported", which costs only that chart. A band's schema holds no chart and Insert puts none in a note, so a chart in a header, a footer or a footnote, found and charged as the body's, keeps its title as text, the one readChart reads. Each chart that doesn't come in counts toward charts-dropped.
The import returns warnings beside the update: blocks-flattened, images-unshown, images-dropped, diagrams-dropped, charts-dropped, section-pages-dropped, even-bands-dropped, band-images-unanchored, band-content-dropped, toc-entries-changed and text-simplified, each with a count. The runner logs them on the job's line, and an Import… answers with their summary (§ An Import… answers with what the file held that did not come over).
A file the reader can't read is a 400 "Not a valid docx file", whether the zip is broken, the XML malformed or the reader slipped. An encrypted docx is an OLE file holding an EncryptedPackage stream, and gets its own 400, "This document is password-protected. Remove the password in Word and import it again." A cap is a 413 (§ Zip guards).
A picture, a chart or a text box Word anchors with wrapNone, alone in its anchor, in the body and outside a table, comes in in front of the text, or behind it with behindDoc (anchorPlacement, import/doc/drawings.ts). Its width caps at the paper, not the column. Its horizontal offset is read as a wrapped picture's is (§ A docx import replaces the document), from the area relativeFrom names and kept on the paper's whole px, by the width the node stores, and an align becomes the offset it puts the picture at. Its vertical offset is read from the paragraph's first line: one relative to the line as it stands, and one relative to the paragraph less the space before Word draws above the line, as LibreOffice counts that offset from the top of the space before. One relative to the page or a margin, an aligned one or a simple position has no place on a page Eigen doesn't draw, so the picture goes to its paragraph's top.
The reader gives these offsets from the text column and the first line, because a run knows nothing of the structure around its paragraph. placeAnchors (import/doc/assemble.ts) converts them once the structure is built: it subtracts the indents of the lists and quotes around the paragraph and its own left indent, as stored, and adds the padding above its text, through the writer's own anchorIndentPx and anchorPaddingPx. It also moves each figure to its paragraph's start, as the editor keeps it there. isFigureOnly counts such a figure as whitespace, so a heading that anchors one stays a heading, and caption handling counts only the figures in the flow, so a block figure beside one still takes the Caption line after it.
So a Word file comes back from an Eigen round trip changed in these ways:
- A picture placed from the page or a margin, or aligned vertically, moves to its paragraph's top.
- A horizontal alignment becomes an offset, at the same place.
- A
wrapNonegroup or canvas is a block, whatever it holds: one offset would stack its pictures, and its text boxes would draw elsewhere (overText). So is awrapNonepicture in a table cell or a note. In a band, abehindDocpicture is dropped and any other anchor is a block (§ A docx import replaces the document). relativeHeightisn't read, so each layer stacks in document order.- The anchor moves to its paragraph's start, which Word doesn't show, and comes back relative to the
columnand theline.
A wps shape that holds text, alone in its drawing, in the body or a table cell, comes in as a text box (import/doc/text-boxes.ts, called from readDrawing), placed as a picture is: wrapNone in front of or behind the text, wrapSquare, wrapTight and wrapThrough wrapped on their side, wrapTopAndBottom and wp:inline in the flow, and wrapNone in a cell in the flow. Its wp:extent gives its width, capped at the paper or the column, and its height as a minimum, none where a:spAutoFit grows it. Its fill and line come from wps:spPr, else its wps:style refs through the theme, a gradient's first stop for a gradient and the nearest cell-border style and width for its line; its insets and anchor from wps:bodyPr, Word's own insets read as none; roundRect as rounded corners. Its paragraphs are read in a scope of their own (textBoxScope), so they store no spacing and their pictures draw in line, then become paragraphs as a note's do (storyParagraphs): a list, a heading or a table flattens, counted as text-simplified. A note or a box inside a box becomes its text, counted the same way, as a story holds no story. A box around nothing but a picture or a chart stays a box when Word paints it, by a fill or a line (paintsBox); an unpainted one becomes that node at the box's width and place, counted. Every box gets a fresh story id. A box that holds no text and draws nothing is dropped, uncounted.
What stays as before: a box in a band or a note, or in a group or a canvas, keeps its paragraphs after its anchor's; a box that only captions its picture still becomes the picture's caption. A box in the flow is no figure for the caption rules (isInFlowFigure), so it takes no Caption line, and a paragraph that holds it beside text keeps its heading. A paragraph that holds only the box is aligned and spaced as a figure's (isFrameOnly): its w:jc becomes the box's alignment and its space past the figure's margin its own, so the writer's paragraph for the box comes back as it went out instead of gaining space on every round trip. A linked box's text is in the chain's first box, as Word stores it, and each later box comes in empty, counted. Rotation, vertical text and any geometry but rect and roundRect are counted as text-simplified, and a box drawn simpler for several reasons counts once. A VML shape, rect or roundrect holding a v:textbox outside a Fallback reads its CSS style, fillcolor, strokecolor, strokeweight and the textbox's inset. A color is a hex, with Word's theme slot after it, as in #4f81bd [3204], dropped; a named one reads as none. A length with no unit is px in the style, as a browser reads it, and EMU in strokeweight and inset, as LibreOffice reads them (vmlPx, import/doc/package.ts, which the VML picture reader shares). Word's VML names the column and the paragraph text in mso-position-horizontal-relative and mso-position-vertical-relative (the corpus has no column or paragraph), so an absolute shape relative to them, or to the line, is placed, a negative z-index behind. Its w10:wrap says how the body's text wraps it: square, tight or through wrap it on the side mso-position-horizontal names, else, as for Word's absolute, on the side of the column its center is on, by margin-left and width, as a DrawingML offset's and a floating table's (floatSide), and topAndBottom puts it in the flow. mso-wrap-style is the wrap of the box's own text, so it says nothing about the body's. The textbox's or the shape's mso-fit-shape-to-text grows the box, and the shape's v-text-anchor aligns its text. One placed from the page or the margin with no wrap keeps its paragraphs after its anchor's.
Most Word files set their body's line spacing and spacing in Normal: 583 files of the corpus have a 1.15 Normal, and Google Docs writes the same values again on every paragraph. Read as they stand, every paragraph would get a format and draw unlike typed text. So the reader takes the docx's Normal (docDefaults and the default paragraph style) as the doc's Normal (createReader in import/doc/paragraphs.ts): its font through the font map, its size, its before and after, and its auto line in that font's own line, single for any other rule. A paragraph (readParagraph) is compared with that Normal, and a heading, a title or a quote with its own style, whose look is the node's, and gets a value only where it differs. Against its style, a node reads a line only where the paragraph or its list level sets one: the writer writes a rescaled line on the paragraph itself, so a line taken from the style was never rescaled, and keeps none whatever family the runs are in. A line it sets is compared with the style's in the style's own font, so a heading at its style's line keeps none whichever family that font maps to, as with Office 2007's Cambria headings over a Calibri body. A value equal to what the node draws in Eigen is none too, as the writer's style holds it and it would not come back: a 0 before on an empty heading that comes in as a paragraph of a Normal with none, a 0 after or a single line on a note's paragraph, which draws Word's FootnoteText (ownTextLook, document/looks.ts, which the writer's styles are built from). So a size or a space set equal to what the node draws follows the node later: a run at an h2's size drops to the body's when the heading becomes a paragraph, where Word keeps direct formatting whatever its value. A run gets a font mark only for a font other than the Normal's, Inter included, and a size or colour mark only for one apart from the body's. A line spacing is an auto line as its multiple; exact and atLeast are heights in points, which read differently at each size, so they give none.
The writer writes the container's look as direct spacing: an item's and a quote's after, a table's margin on the block below it (or the larger margin of a list or quote the table ends, BLOCK_MARGINS in looks.ts, as a table holds no space after), no space above the first block. So the reader takes spacing only on a block outside any list, quote or cell. Under a table the editor adds a paragraph's space before, its own or the Normal's, which it draws as padding, to the table's margin, while a heading's style margin collapses with it to the larger; so the writer writes the two summed (withBefore) and the reader keeps only what passes the margin, and none where that is the style's or the Normal's (formatOf and gapBelowTable in assemble.ts). A quote placed into a list item has its lists renumbered by the numbered lists around the item, as a table's are (numberWithin), since each was read as a flow of its own. The writer also rescales the line of a paragraph all in one family (familyLineScale), and the reader divides it out through the same function, over the same faces: every run, whitespace included, but the space a task's or a note's text starts with, which the writer writes apart beside the checkbox or the note's number, and the paragraph style's run, which stands for the mark as the writer writes the mark no font of its own, each at its size, the tallest against the style's run's family at the largest size. So an Eigen doc's paragraphs read back with no format, and a Word paragraph comes back from our docx with the line its first import read. A paragraph a Word page break splits (DOCS.md) keeps its space before on the first half and its space after on the last, where Word draws them. Indents are structure first: an indent nests a paragraph into a list item or a quote (§ A docx import replaces the document), so only the indent past the item's text is the paragraph's, an item's own indent holds its number, and a quote's paragraph keeps none. Every value is bounded through readParagraphFormat, as a peer's is.
The editor repairs some content the moment it loads: prosemirror-tables pads a row short of its table's columns, and on the next pass gives the new cells their column's width, and StarterKit's TrailingNode appends an empty paragraph to a doc that ends in anything else. Each repair is a Yjs update nobody typed, and two people opening a fresh import together write it twice. So the reader writes well-formed tables (import/doc/tables.ts): a row Word draws short of the grid ends in a cell over the columns it misses, with their widths, and a merge stops where a row leaves it out, so fixTables finds nothing to repair. A row stops at Word's 63 columns, its last cell holding the text of any past them: the widest row sets every row's width in the TableMap the editor builds for each table, and one row of 20,000 cells over 5,000 more cost 2.3 GB. A table that merges is split into tables of MAX_MERGED_ROWS (2,000) rows, a merge across the split starting again in the next: findWidth, under every TableMap, rescans the rows above each row after a rowspan, so 49,000 rows took 27 s to open, and a part now costs at most 0.9 s. Repairing them afterwards is no option on untrusted input: fixTables' transaction keeps a copy of the table for every repair, so 20,000 rows with one bad width each cost 3.4 GB and 6 s. asOpened (import/doc/transform.ts) is the safety net: it refuses a table whose repairs times rows pass MAX_TABLE_REPAIRS before any repair runs, runs fixTables once, refuses with a 413 a table a second pass would still repair (a repair can leave another, and the reader writes tables that need none), and ends the doc in a paragraph before importDocxToEigendocUpdate encodes the update. apps/api/src/test/import/doc/tables.test.ts expects fixTables to find nothing on ragged, colliding and mismatched tables, and transform.test.ts opens the stored doc with the editor's plugins and expects no change.
The Yjs update costs memory per node and per mark, not per XML tag: 720,000 tags of marked tabs and breaks emit 3.6 million nodes and marks and cost 4.4 GB in the transform, which every import's limit has to hold. So after the reader walks the document and before anything is built from it, sizeOf (import/doc/from-docx.ts) counts each node and mark as one, a string attribute as one per 512 characters (the update spells a link's href out for every text node it marks) and every table's area, the slots its TableMap holds, as one per 64 (a slot costs about 16 bytes, which a span or a padded row fills at no node's cost). The same pass counts the text's characters and every string attribute's: one w:t compresses a gigabyte into a small upload, a link's href is spelled out again for every text node it marks, and the update holds every character, which a client opens in one WebSocket message of at most 128 MB. Past MAX_DOCX_WEIGHT, or past MAX_DOCX_TEXT characters (16 million, the corpus's most 511,898), the import is 413 "This document is too large to import". An equation's MathML is charged at the same rate as the reader writes it (the package's mathml budget, import/doc/package.ts), not at the weigh: a space in m:t writes 26 characters of MathML, so a 22 KB upload of spaces in equations would hold 400M characters by the time the weigh refuses it. The parse itself, which the weighing comes too late for, has the package's XML budget.
readPptx (apps/api/src/lib/import/slides/from-pptx.ts) reads the PresentationML into the canvas's own elements in the transform process, and importPptxToSlidesUpdate (import/slides/transform.ts) encodes them as a Yjs update of frames and elements. One slide is one frame. A Convert makes the deck in the slide size nearest the file's p:sldSz, SLIDE_SIZES[nearestSlideSize(cx, cy)] (packages/lib/src/vector/frames.ts), and the update's meta.slideSize says which, so a 4:3 file makes a 4:3 deck and a 16:10 or A4 one is letterboxed, centred, into the nearer size. The update's meta.slideWidth is the width that keeps the file's pt per px: the file's own width when its shape matches, and wider when it is letterboxed between bands at the sides. That width is held to the 128 to 4032 pt a deck may have, and the shapes take the held width's pt per px, so a pt stays a pt: a slide over 42 in tall overhangs its 4:3 frame top and bottom, and a slide under an inch tall sits inside its frame. An insert passes the open deck's page, read from its meta with readSlidePage, and a file of another size is letterboxed into it, its text scaling with its shapes; the deck's meta stays as it was. A replace passes none, as a Convert does. Either way a text size is snapped to the half points of the deck's pt (snapSlideFontSize), so a 44 pt title reads 44 pt and not 44.000000001. A Convert and a replace write the update with replaceSlidesInYjs, its meta included; an insert writes it after the slide the user is on with insertSlidesIntoYjs, under fresh ids, so nothing in the open deck is rewritten and its undo and comments stay valid (SLIDES.md). Hidden slides come in too; speaker notes do not.
A line 0 wide with a colour is a 1 px stroke, the canvas's thinnest, as PowerPoint and LibreOffice draw it as a hairline; the writer writes it back at that width, and writes a:noFill for no border. What the canvas has a kind for comes in as that kind: a rectangle, rounded rectangle, ellipse, diamond and pie, a straight line or connector (an arrow where it has a head), a bent or curved connector or an open freeform line with a head as an elbow, sharp or curved arrow (§ A connector or open freeform with a head is an arrow), a PNG, JPEG, GIF or SVG picture, and each text body as a text box or as the text of a shape. A picture stretches over its box, unless its a:srcRect crops it evenly on one axis, as the export writes a fitted picture: a centered negative crop reads as contain and a centered positive one under 50% as cover, both in the whole box (objectFitOf, import/slides/shapes.ts). The reader never decodes a picture, so it can't check the crop against the picture's aspect, and takes any such centered crop as that fit. A side that doesn't read counts as uncropped. Any other crop, uneven, on both axes or cutting the whole picture away, stretches the picture and counts as a picture's crop not imported (crops-dropped). A shape keeps its text: a rectangle, rounded rectangle, ellipse, diamond, pie, or a shape drawn as one closed path holds its text itself, even when it has no fill or outline, so the text moves and resizes with the shape. The file says which straight rectangle is a text box: one marked txBox="1", a placeholder (a title, body, date, footer or slide number), or one whose own body grows the shape to its text (a:spAutoFit) comes in as a text box, a rectangle with autoFit that grows with its text, painted or not. An empty one comes in as a text box only when it is marked txBox="1" and paints a fill or a border, as the export writes an empty text box. An empty rectangle that paints nothing is left out, since it draws nothing. Any other straight rectangle with text comes in as a plain rectangle, which lets it spill, as PowerPoint does (readShape, import/slides/shapes.ts). A LibreOffice shape grows with its text by default and is saved with a:spAutoFit, so a deck LibreOffice saved may bring its filled rectangles in as text boxes. Every other text box this reader makes, such as the text box on top below and the box that says what was left out, is such a rectangle too. Any other shape marked txBox="1" keeps its text in a text box on top, because the mark says the text belongs to a box and not to the shape. So a painted rounded text box, which the export writes as a custGeom marked txBox="1", comes back as a path with a text box on top. The text also comes as a text box on top for a SmartArt shape with a text box of its own (dsp:txXfrm), turned text (vert), a line or connector, a shape the reader can't draw as paths, and a freeform of several paths or an open one. The text's box draws one size, color, font and letter spacing, those of the run style most of its text is in, while each run keeps its bold, italic, underline and strike. So a box whose runs differ in size, color, font or letter spacing counts as simplified (richTextOf, import/slides/text.ts). A layout's and master's shapes are copied beneath every slide that shows them, and groups are flattened. Any other shape comes in as editable paths (§ A shape no kind draws is a path). A chart comes in as an Eigen chart through readChart (ooxml/ooxml-chart.ts), from the values the file caches: a column, bar, line or pie chart, a 3-D one drawn flat, with its series colours through the slide's colour map, or the chart's own where it overrides that, and its text in the chart's own text colour or else that map's text colour, so it reads on a dark theme. A chart the spec can't draw as the file does (area, scatter, doughnut, radar, bubble, stock, surface, pie of pie, a combo, a stacked line, a secondary, log or reversed value axis) leaves a box naming its kind as PowerPoint's Insert Chart does (readChart's name) and its title. A chart the reader's work budget can no longer cover leaves the plain "Chart not imported" box, which costs only that graphic. A SmartArt graphic comes in as the shapes PowerPoint cached of it (§ A SmartArt graphic comes in as the shapes PowerPoint cached of it). A table, embedded object or picture left out, and a SmartArt graphic with no shapes cached, leave a box saying what was there too, at its place, so the slide shows the gap instead of hiding it. Every box is see-through, a dashed outline and its label in the slide's text colour, so a title it overlaps stays readable. A pattern fill becomes its foreground color, a gradient keeps its first and last stop, and a picture fill on a shape is left out, counted as a picture not imported (images-dropped). These gaps are CANVAS-TODO rows.
Each loss is counted once per source shape, so a master's background is one loss however many slides show it, and the Import… summary counts them (§ An Import… answers with what the file held that did not come over). A file with no slides converts to a deck with none, which the Slides editor seeds on first open as it does a new one; An Import… of such a file writes nothing and answers "This presentation has no slides".
The pictures are named with a random prefix per import, so a second import of the same file into one deck overwrites none of the first's. An Import that fails after its pictures are saved, a revoked write or a closed tab, leaves them in the deck's media folder, counted against quota, as Eigen keeps a document's unused media. Untrusted bytes are bounded as for a docx: the reader parses only the presentation, slides, layouts, masters, themes, a SmartArt graphic's data and drawing parts, their relationships and the content types, never notes, a SmartArt graphic's layout, style and colour parts, or embeddings, and a chart's part reached only by a chart relationship under ppt/charts/, never its own relationships, each part it parses spending the package's one parse budget (openPackage, DOCUMENT-TRANSFORMS.md), and caps the slides, the elements, the reader's work (one budget over the children it scans, the colours it resolves, the text it reads, the shapes it walks, each geometry's guides, commands, arcs, nesting outlines and simplifying passes, and each chart part's elements and the points its caches declare, for every colour map it is painted with), and the update's weight. Past a cap the import is a 413 "This presentation is too large to import". Past the pictures' caps a picture is left out instead, by the readers' one rule (§ An Import… answers). The caps live in import/slides/. A macro deck, template or slideshow saved under .pptx is refused by its content type with a message to save it as a .pptx.
A SmartArt graphic is a data model, its nodes and their text, which PowerPoint lays out through a layout definition the reader can't run. PowerPoint also saves the result of that layout: a drawing part of plain DrawingML shapes in the dsp namespace. The data model names it by dsp:dataModelExt's relationship id, a relationship of the slide and not of the data part (diagramDrawingId, ooxml/drawingml.ts, which the docx reader calls too). readGraphicFrame (import/slides/shapes.ts) walks that drawing with the slide's own shape reader, which reads a shape's children in the shape's own namespace, so a dsp:sp paints through the slide's colour map and its dsp:style as a p:sp does. The drawing's coordinates start at the graphic frame's corner, at scale 1, and its links resolve through the drawing part's own relationships. A shape's text sits in the box its dsp:txXfrm gives, turned by the shape's turn plus the box's own, as LibreOffice draws it, so a hexagon PowerPoint turned upright keeps its text level. Since the text has a box of its own, even a straight rectangle splits into a painted rectangle and a text box on top.
The shapes stay loose, as a pptx group is flattened too: the canvas has no groups. The diagram itself is gone, so editing a node's text lays nothing out again and the pptx export writes plain shapes. Each graphic drawn this way counts once as diagrams-flattened, which the toast says as "SmartArt graphics lost their layout", so it still names what changed. A file that cached no drawing, as LibreOffice writes one, and a drawing cached with no shapes, as 13 of LibreOffice's own test decks in the corpus hold, leave the dashed box instead, with the data model's node text under its label (diagramTextBodies, its node and assistant points), counted as diagrams-dropped.
A pptx shape's outline is a preset, one of ECMA-376's 187 named shapes with adjust values, or a custGeom, its own path list, both written in DrawingML's guide formulas. The few presets a canvas kind draws, rectangle, rounded rectangle, ellipse, diamond and their flowchart twins, come in as that kind (PRESET_KINDS, import/slides/preset-geometry.ts), so they keep their corners and binding; a pie keeps its angles. Every other shape becomes canvas paths (CANVAS.md) at the shape's box, so a star, a block arrow, a map outline or a freeform drawing stays editable and recolorable. customGeometry (custom-geometry.ts) turns a custGeom, and presetGeometry a preset through its definition in Ecma's presetShapeDefinitions.xml, committed unchanged beside them with its NOTICE, into one path per way the geometry paints. guides.ts evaluates the formulas without eval, every result finite. The file is the spec's own, read once per process, so every preset draws as the spec defines it; upArrow, which the file lacks, is a named override in code.
The paths follow how PowerPoint and LibreOffice paint them. A shading path (darken, lighten and their Less forms, a can's top or a cube's side) fills in the shape's fill blended toward black or white, by LibreOffice's fractions. A filled path left open fills as if closed and strokes open, so it becomes a closed fill and an open line. Each subpath winds by how deep it nests among its path's others, so a contour inside another is a hole, as LibreOffice's even-odd fill draws it, on the canvas's nonzero fill and under its even-odd hatch alike. Its depth is found from a point on its first segment's curve, tested against the others' outlines, each curve cut into chords within 0.1% of the box, so a thin ring such as a donut's is still a hole: a chord's middle lies inside its curve, and ends and middles alone make a circle an octagon 7.6% inside it. Filled paths painting alike become one path element only while their boxes don't overlap: the export writes an element as one a:path, which LibreOffice fills even-odd, so a firewall's bricks concatenated onto its wall would come back as holes. Overlapping ones are elements of their own at the shape's box, in the file's order; a shape is 32 geometries at most, so 64 path elements at most with an open fill's line apart: past them an alike path concatenates overlapping or not, and one painting a new way leaves the shape only its text, counted as simplified. A flipped shape's points are mirrored across its box as they are read, before the caps measure the path, since a path, unlike a picture, can carry its mirror. A shape drawn as one closed path holds its text, even when that path paints nothing, and the text of a shape drawn as several paths or an open one sits on top in a transparent box. The text's inset loses only the border the holding path draws, so a path marked stroke="0" keeps the file's whole inset however wide the shape's line is. One conversion is kept per geometry and size, so a layout's shape converts once for every slide that shows it.
A geometry past the canvas's path caps is simplified, dropping each point that lies within 0.1 to 0.4% of the box of the line its neighbours draw, and past them still, or past the reader's work budget, the shape keeps only its text and counts as simplified. Each stage, the arcs, the nesting and each simplifying pass, asks the budget before it begins, so a geometry it can't cover costs the deck nothing more. An arc's arrowheads are lost and counted, since a path draws none, and so are those of an open freeform no arrow draws: one of lines and curves at once, of several subpaths, or filled. PowerPoint draws none on a closed path. Shadows, glows and 3-D are dropped as on every shape.
A path draws no arrowheads, so a bent or curved connector with a head comes in as the arrow kind, which does (connectorArrow, import/slides/connector.ts), and so does an open freeform line with a head (freeformArrow). Its points are the shape's path after the flips, on the slide, with angle 0, since an elbow arrow's route is never turned. Each head is the nearest the canvas has, as on a straight connector (ARROWHEAD_BY_LINE_END), at the canvas's one size for that head: the file's head width and length have no field to go to.
A bent connector is an elbow arrow with every bend pinned (fixedSegments, CANVAS.md). Unpinned, the router would route its two ends afresh, and puts the bends where it likes, not where the file's adjust values do. A pin holds only a leg between two others and the elbow draws only right angles, so a two-leg bentConnector2, and a connector turned off a quarter turn, is a sharp arrow through its corners instead. Adjust values that make two bends meet or a leg go straight on leave that point out, since a leg of no length points a head nowhere.
A curved connector is a curved arrow through points along its curve. No kind draws a Bézier with heads: the arrow's curve runs through its points (arrowCurveBeziers). A point every 40 px or so keeps that curve within about a pixel of PowerPoint's at a 400 by 200 px connector (shapes.test.ts pins it), and keeps the last leg, which a head points along, long enough for a whole head. Each cubic is cut into at most 8 legs, so a hostile extent can't grow the points.
A connector without a head stays a path: it has nothing to lose, and a path comes back from the pptx export as it went.
The pptx export writes an arrow of more than two points as a freeform with its line ends: its points joined by lines, or by the cubics of the canvas's own curve through them for a curved arrow. The reader reads such a freeform back as the arrow, so an imported connector keeps its heads on a second import. A freeform of lines only is a sharp arrow through its points, and an elbow arrow pinned along its bends when every leg runs square, as a bent connector is. A freeform of curves only is a curved arrow. Where each curve's control points lie within a pixel of the canvas's own curve through the curves' ends, the arrow runs through those ends, which is the arrow the export wrote. Any other curve is cut into points as a curved connector's is. The freeform's path keeps a point to a ten-thousandth of its box, so the points come back within that, and the pptx round-trip gate lists it ("an arrow's points as its freeform's path keeps them").
POST /contacts/:ownerId/export concatenates the stored vCards and POST /calendar/:ownerId/export splices the stored VCALENDAR lines. There is no renderer, transform or sanitizer, because the stored bytes are already the format. See CONTACTS.md and CALENDAR.md.
- SHEETS.md: the sheets HTML and the xlsx round-trip drifts
- DOCUMENT-TRANSFORMS.md: the runner, its limits and the process boundary
- CANVAS.md: the scene and the arrow label
- PREVIEWS.md: the previews that share these renderers