Documents
On this page
Word import and export, PDF files, full-page HTML and email export. Each format is its own package, so an editor only bundles what it uses:
| Package | Contents |
|---|---|
@better-rich-text/format-docx |
DOCX export and import, plus the ZIP and XML readers they use. No DOM, runs in Node. |
@better-rich-text/format-email |
Email-safe HTML: CSS inlining, table layout, plain-text alternative. No DOM. |
@better-rich-text/format-pdf |
PDF files written from the document model, in the browser or Node. No dependencies. Loaded on demand by the documents plugin. |
@better-rich-text/format-html |
parseHtmlDocument and serializeHtmlDocument for full HTML pages. |
@better-rich-text/plugin-documents |
Editor plugin (commands, hooks, the documents service), the print pipeline and the toolbar feature. |
@better-rich-text/plugin-page-break |
Page breaks that print, export to Word and survive HTML round trips. See Page breaks. |
Setup
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { createDocumentsPlugin, documentsFeature } from '@better-rich-text/plugin-documents';
const editor = createRichTextEditor('#editor', {
plugins: [createDocumentsPlugin()],
features: [...standardPreset.features, documentsFeature],
toolbar: `documents | ${standardPreset.toolbar}`,
});documents is a menu with Import Word document, Export Word, Download PDF, Export PDF and Document properties. Each action is also a toolbar item of its own: importWord, exportWord, downloadPdf, exportPdf and documentProperties. Dropping a .docx file on the editor opens the import preview.
When a Word file has comments or tracked changes, the import preview lists how many and offers two choices: keep the comments, and keep the tracked changes as suggestions, accept them all or reject them all.
Plugin options:
| Option | Default | Purpose |
|---|---|---|
filename |
the title, else document |
Base name for downloads. |
docx.page |
A4 portrait, 25.4 mm margins | Page size, orientation, margins, header, footer and page numbers written to the Word file. Overrides pdf.settings; the document's own page settings override both. |
docx.defaultFont |
Calibri 11pt | Body font in styles.xml. |
docx.comments |
true |
Write comment threads as Word comments. |
docx.trackedChanges |
'track' |
Pending suggestions: track writes Word tracked changes, accept and reject apply or drop them. |
docx.authorInitials(author) |
the user directory, else from the name | Initials shown on Word comments. |
docx.import |
{ comments?, trackedChanges?, resolveAuthor? } for reading Word files (see Import). |
|
fetchImage(src, node) |
fetch (same-origin credentials) |
Loads images that are not data: or blob: URLs for embedding. |
uploadImage(image) |
data URL | Stores images found in imported Word files. With no option set, the import dialog uses the image plugin's upload provider when there is one. |
maxImportBytes |
50 MB | Largest file accepted for import. |
pdf.renderer |
none | Server PDF renderer (see Server PDF). |
pdf.settings |
A4, 18 mm, page numbers | Default page settings for PDF files and printing. |
pdf.builtin |
true |
Write PDFs in the browser. false hides Download PDF and makes Export PDF open the print dialog. |
pdf.fonts |
none | TrueType fonts for text the built-in fonts cannot show, or an async function returning them (see Fonts). |
pdf.options |
Body fontFamily (sans-serif or serif), fontSize, pageNumberFormat, preferEmbeddedFonts, decodeImage, compress. |
|
email |
Default exportEmail options. |
|
scripts |
drop |
Full-page script policy: drop or preserve (kept as inert data). |
save(blob, filename) |
browser download | Replace the download, for example to upload the file. |
Commands
All commands are validated and appear in editor.toolDefinitions().
| Command | Input | Result |
|---|---|---|
document.setProperties |
{ title?, description?, keywords?, language?, direction? } (null clears) |
Undoable. Returns the new properties. |
document.getProperties |
none | { title, description, keywords, language, direction } |
document.setFullPageHtml |
{ html } |
Replaces body and head metadata in one undoable step. |
document.getFullPageHtml |
{ includeScripts? } |
{ html }, a complete page. |
document.commitImport |
{ root, metadata?, annotations?, mode?: 'replace' | 'insert' } |
Commits an import candidate with its comment threads and suggestions. Undoable. |
document.exportDocx |
{ filename?, comments?, trackedChanges? } |
Starts a Word download. |
document.downloadPdf |
print settings and filename? |
Writes the PDF in the browser and downloads it. |
document.exportPdf |
print settings and filename? |
Server renderer when configured, otherwise the same as document.downloadPdf (or the print dialog when pdf.builtin is false). |
document.print |
print settings | Always opens the print dialog. |
document.exportEmail |
{ layout?, width?, preheader?, baseUrl? } |
{ html, text, issues } |
File work is asynchronous, so it lives on the documents service rather than inside a transaction:
const docs = editor.editor.service<DocumentsService>('documents')!;
const file = await docs.exportDocx(); // { blob, filename, issues, revision }
await docs.exportDocx('clean', { comments: false, trackedChanges: 'accept' });
const candidate = await docs.prepareImport(file); // nothing changes yet
candidate.review; // { comments: 2, trackedChanges: 5 }
docs.commit(candidate, 'replace'); // one undoable step
docs.commit(candidate, 'replace', { comments: false, trackedChanges: 'reject' });
docs.pageSettings(); // print and Word page settings for this document
docs.fullPageHtml(); // complete HTML page
docs.exportEmail({ preheader: 'This week' }); // { html, text, issues }
const pdf = await docs.renderPdf({ size: 'Letter' }); // { blob, filename, pages, issues, revision }
await docs.downloadPdf(document, { size: 'Letter' });
await docs.exportPdf(document, { size: 'Letter' }); // renderer, built-in or printExports read the document at the moment they are called. Typing during a slow export does not change the file being written, and the result carries the revision it was made from.
Word
Export
exportDocx(root, options) writes a real OOXML package (document.xml, styles.xml, numbering.xml, settings.xml, relationships, document properties and media). It does not wrap HTML in an altChunk.
| Content | Word output |
|---|---|
| Paragraphs, alignment, indent, line height, spacing, direction | Paragraph properties |
| Headings 1 to 6 | Heading1 to Heading6 styles with outline levels; anchors become bookmarks |
| Bold, italic, underline styles, strikethrough, superscript, subscript, small caps, inline code | Run properties |
| Text colour, highlight (any colour), font family, font size | w:color, w:shd, w:rFonts, w:sz |
| Links | External hyperlink relationships; #anchor links point at bookmarks |
| Bulleted, numbered and nested lists, list start and style | Numbering definitions; each list restarts its numbering |
| Checklists | ☐ and ☒ bullets |
| Tables | Grid columns from column widths, gridSpan for merged columns, vMerge for merged rows, cell shading, per-side borders, header rows that repeat on each page, captions |
| Images | Embedded media sized from the image attributes or the file header, capped to the text width |
| Quotes, code blocks, horizontal lines | Quote and code styles, a bottom-bordered paragraph |
| Footnotes | footnotes.xml, when the schema has a footnote node |
| Page breaks | A paragraph holding <w:br w:type="page"/>, when the schema has a pageBreak node |
Content with no Word equivalent keeps its text and is listed in issues.
Review content and page furniture come from the options:
| Option | Word output |
|---|---|
annotations (editor.state.annotations) |
Comment threads and suggestion records to write |
comments (default true) |
Each message of a thread is a Word comment in comments.xml, with author, initials and date. The anchored text sits between w:commentRangeStart and w:commentRangeEnd, followed by a w:commentReference run. Replies point at their thread through w15:paraIdParent in commentsExtended.xml, and resolved threads are marked w15:done. Mentions become @Name. |
trackedChanges: 'track' (default) |
Suggested insertions and deletions become w:ins and w:del (deleted text in w:delText) with author and date. Format suggestions become w:rPrChange with the previous formatting. Proposed paragraph breaks, removed breaks and whole blocks become inserted or deleted paragraph marks, with w:ins or w:del on table rows. Paragraph type changes, such as paragraph to heading, become w:pPrChange. |
trackedChanges: 'accept' | 'reject' |
Every pending suggestion is applied or dropped first, so the file has no tracked changes. |
authorInitials(author) |
Initials on comments; by default the first letters of the first and last names. |
page.header, page.footer |
header1.xml (centred) and footer1.xml (on the left), referenced from the section. {title} and {date} become Word TITLE and DATE fields. |
page.pageNumbers |
"Page 3 of 7" on the right of the footer, with PAGE and NUMPAGES fields. |
page.size, page.orientation, page.margins |
w:pgSz and w:pgMar. Sizes: A4, A5, A3, Letter, Legal and Tabloid. |
The documents plugin passes the document's annotations, and takes the header, footer and page numbers from the PDF settings unless docx.page or the document says otherwise. Page numbers are on by default, as in print.
applySuggestions(root, annotations, mode) accepts or rejects every pending suggestion in a tree without an editor; resolveTrackedChanges(schema, root, annotations, mode) does the same, normalises the result and keeps comment anchors attached.
Import
importDocx(bytes, { schema, ids }) reads the package, resolves styles and numbering, and builds semantic HTML that then goes through the schema parser. Anything the schema does not accept is dropped there, the same as paste.
Heading, title, quote and code paragraph styles map to those blocks, following
basedOnchains.Numbered paragraphs become nested lists by level, with list style and start number.
Merged cells, shading, borders and header rows come back as table attributes.
Images go to
uploadImage, or become data URLs.Content controls and field results keep their text.
Comments become comment threads, anchored where their ranges were. Replies join their thread and resolved threads stay resolved when the file has
commentsExtended.xml(Word 2013 and later, LibreOffice). A comment with no range is anchored at its reference, or kept as an orphaned thread.Tracked changes (
w:ins,w:del,w:moveTo,w:moveFrom,w:rPrChange, inserted and deleted paragraph marks) become pending suggestions with the original author and date. Changes next to each other by one author at one time form one suggestion. Inserted and deleted paragraph marks become proposed paragraph breaks and joins.The last section's page size, orientation and margins, and the default header and footer text, become
result.page.TITLEand date fields come back as{title}and{date}, andPAGEandNUMPAGESfields (with "Page … of …" around them) aspageNumbers: true. The documents plugin keeps these in the document'spagemetadata, which print and the next Word export use.Footnotes become superscript markers and a numbered list at the end.
Page breaks (
w:br w:type="page"andw:pageBreakBefore) becomepageBreaknodes when the schema has one, splitting the paragraph at the break. Otherwise they are dropped.
Import options for the review layer:
| Option | Default | Purpose |
|---|---|---|
comments |
true |
Read comments into threads. |
trackedChanges |
'track' |
track keeps changes as suggestions, accept and reject apply or drop them. track needs the suggestions plugin in the schema; without it changes are accepted and a warning is reported. |
resolveAuthor({ name, initials }) |
word:<name> |
Maps a Word author to a user id. The documents plugin matches names against the user directory (@better-rich-text/users): the current user, then a search. |
The result adds annotations (comment threads and structural suggestion records, ready for editor.state.annotations), page and review ({ comments, trackedChanges } counts for previews). document.commitImport adds the annotations with the content.
The reader rejects encrypted and ZIP64 archives, unsafe paths and DTDs, and enforces limits on entry count, total size, compression ratio and XML node count before content is committed.
Validation
validateDocxPackage(bytes) checks a package before it is offered for download or after it arrives: the archive opens, every XML part is well-formed and has a content type, relationships point at existing parts of the right type, comment ids are unique, every comment range opens before it closes and names an existing comment, revision ids are unique and have an author, deleted text is in w:delText inside a deletion, and header and footer references point at header and footer parts.
There are three ways to make a PDF:
| Way | How | Use it for |
|---|---|---|
| In the browser (default) | Download PDF, or Export PDF without a renderer | A file straight away, with no dialog and no server |
Print in the Export PDF dialog, or document.print |
The browser's own print engine and its "Save as PDF" | |
| Server | pdf.renderer (see Server PDF) |
Full CSS fidelity, and scripts that need shaping |
All three take the same settings:
| Setting | Default |
|---|---|
size |
A4 (also A5, A3, Letter, Legal, Tabloid) |
orientation |
portrait |
margins |
18 mm each side |
header, footer |
none; {title} and {date} are replaced. Files written in the browser also replace {page} and {pages} |
pageNumbers |
true ("Page 3 of 7") |
backgrounds |
true (table shading, highlights and code backgrounds) |
In the browser
@better-rich-text/format-pdf lays the document out from the model, not from a screenshot of the page, and writes a PDF 1.7 file. Text stays selectable and searchable. The documents plugin loads it with a dynamic import() the first time someone makes a PDF, so it adds nothing to the editor bundle until then (about 24 KiB gzip when it loads).
The file has:
paragraphs with bold, italic, underline, strikethrough, superscript, subscript, small caps, inline code, text colour, highlight, font size and font family, aligned left, centre, right or justified;
links: web, mail and phone addresses become link annotations, and
#anchorlinks jump to the heading or anchor they name;headings 1 to 6, which also become bookmarks in the reader's sidebar;
bulleted, numbered (decimal, letters, Roman numerals, Greek letters, reversed, any start) and checklist items, nested to any depth;
quotes, code blocks (wrapped, indentation kept), horizontal rules (solid, dashed, dotted, double and thick) and callouts;
tables with column widths, merged cells, cell shading, borders and alignment, captions, and header rows that repeat on every page;
images: JPEG as is, PNG with transparency, and other formats through the browser;
page breaks from
@better-rich-text/plugin-page-break;running header and footer text, page numbers, and the title, author, description, keywords and language from the document properties.
Lines break at spaces, after hyphens and dashes, and between Chinese, Japanese and Korean characters. A word wider than the line breaks between letters. No hyphens are added. A heading stays on the same page as the line after it, a paragraph never leaves a single line at the bottom or top of a page, and a table row moves to the next page whole unless it is taller than a page, when it splits between its blocks.
renderPdf(settings) returns { blob, filename, pages, issues, revision } without downloading. downloadPdf(document, settings) also saves the file, through save or a download link. Problems that do not stop the export, such as an image that could not be loaded, come back in issues. After a download from the toolbar they appear in a notice.
Fonts
Latin text uses the fonts every PDF reader has: Helvetica (or Times, with pdf.options.fontFamily: 'serif') and Courier for code. They cover Windows-1252: English and the Western European languages, plus curly quotes, dashes, the euro sign and similar punctuation.
For other scripts, such as Greek, Cyrillic, Chinese, Japanese and Korean, supply TrueType fonts. A character the built-in fonts cannot show uses the first supplied font that has it, so one font per script is enough. Only the glyphs used are embedded, and a ToUnicode map keeps the text copyable and searchable. Pass a function to load large fonts only when someone exports:
const load = async (url: string) => new Uint8Array(await (await fetch(url)).arrayBuffer());
createDocumentsPlugin({
pdf: {
fonts: async () => [
{ data: await load('/fonts/NotoSansJP-Regular.ttf') },
{ data: await load('/fonts/NotoSansJP-Bold.ttf'), bold: true },
],
},
});Fonts need TrueType outlines:
.ttffiles, or.ttccollections withindex. OpenType fonts with CFF outlines (most.otffiles) are reported and skipped. Google Fonts serves TrueType files for most families, Noto included.A regular font also covers bold and italic text: bold is drawn with a thin outline and italic with a slant. Supply bold and italic files for better results.
boldanditalicon a font source override what the font says about itself.preferEmbeddedFonts: trueuses the supplied fonts for every character they have, so the whole document is set in your font.Fonts whose licence does not allow embedding are reported and skipped.
A character no font can show is drawn as ?. The export reports it once, with the code pdf-missing-glyphs and the characters in details.characters. Nothing throws.
Images
JPEG files go into the PDF unchanged, and so do PNG files without transparency. PNGs with an alpha channel, a transparent colour or interlacing are decoded and written with a soft mask, so transparency survives. GIF, WebP, BMP and SVG are drawn to a canvas by the browser first. Images load through fetchImage, as for Word export. An image that cannot be loaded or decoded is written as its alternative text and reported.
Without the editor
exportPdf works on any document root, in the browser or in Node:
import { exportPdf } from '@better-rich-text/format-pdf';
const { data, pages, issues } = await exportPdf(root, {
metadata, // title, language, head.author, head.description, head.keywords
size: 'A4',
margins: { top: 20, bottom: 20 },
header: '{title}',
footer: 'Page {page} of {pages}',
pageNumbers: false,
fonts: [{ data: fontBytes }],
fetchImage: async (src) => ({ data: await read(src), contentType: 'image/png' }),
});Node has no canvas, so only JPEG and PNG images are embedded there unless you pass decodeImage(bytes, type) returning RGBA pixels. Streams are compressed with CompressionStream, or written uncompressed where it is missing.
Print renders the published HTML into a hidden, script-free frame with a print stylesheet and opens the print dialog, where "Save as PDF" writes the file. Set pdf.builtin: false to make Export PDF do this instead of writing the file in the browser.
Settings come from pdf.settings, then the document's own page settings (from an imported Word file), then the dialog. Page size and margins use @page. The header, footer and page numbers use paged-media margin boxes, which Chromium 131 and later support, as do headless Chromium, Prince and WeasyPrint. Firefox and Safari print the content with the right size and margins but without those running elements.
The print.document filter receives the complete print HTML before it is rendered, and the print.ready action receives the frame's document once its images have loaded.
Server PDF
Pass a renderer to produce the PDF on a server instead. It receives the same self-contained HTML and returns PDF bytes:
import { createDocumentsPlugin, httpPdfRenderer } from '@better-rich-text/plugin-documents';
createDocumentsPlugin({ pdf: { renderer: httpPdfRenderer('/api/pdf') } });httpPdfRenderer posts a PdfRenderRequest as JSON: { html, title, filename, settings, documentId, revision }. A Node endpoint using Playwright:
import { chromium } from 'playwright';
app.post('/api/pdf', async (req, res) => {
const { html } = req.body;
const browser = await chromium.launch();
try {
const page = await browser.newPage({ javaScriptEnabled: false });
await page.route('**/*', (route) => (route.request().url().startsWith('data:') ? route.continue() : route.abort()));
await page.setContent(html, { waitUntil: 'load' });
const pdf = await page.pdf({ preferCSSPageSize: true, printBackground: true });
res.type('application/pdf').send(pdf);
} finally {
await browser.close();
}
});Run the renderer without network access to the host's internal services, with JavaScript disabled, and with time and size limits. A repeated export cancels the request still in flight instead of starting a second job.
Page breaks
@better-rich-text/plugin-page-break adds a pageBreak block. The editor shows it as a dashed line labelled "Page break"; printing, PDF and Word start a new page there.
import { createPageBreakPlugin, pageBreakFeature } from '@better-rich-text/plugin-page-break';
const editor = createRichTextEditor('#editor', {
plugins: [createPageBreakPlugin()],
features: [...standardPreset.features, pageBreakFeature],
toolbar: `${standardPreset.toolbar} | pageBreak`,
});
editor.commands.pageBreak.insert();pageBreak.insert splits the block at the caret and puts the break between the halves. At the start of a block, or in an empty one, the break goes before it. With a block selected as a node, the break goes after it. Pass { split: false } to put the break after the current block instead. The shortcut is Mod-Enter. Inside a code block Mod-Enter still leaves the block, because codeBlock.exit binds it at a higher priority.
Click a page break, or press an arrow key at the edge of the next or previous block, to select it. Backspace and Delete remove it.
| Option | Default | Purpose |
|---|---|---|
exportAs |
'div' |
div writes <div style="page-break-after: always; break-after: page"></div>, hr puts the same style on an <hr>, comment writes TinyMCE's <!-- pagebreak --> |
separator |
none | Any HTML string to write instead, like TinyMCE's pagebreak_separator. It is read back as a page break |
splitBlock |
true |
false puts the break after the block, like TinyMCE's pagebreak_split_block: false |
shortcut |
'Mod-Enter' |
Another key, or false for none |
selectors |
.page-break, .pagebreak, .pageBreak |
Empty elements that also count as a page break on import |
label |
'Page break' |
Text on the divider, the toolbar button and in the command palette |
Pasted and imported HTML keeps page breaks from:
this plugin's own output, in any
exportAsmode;TinyMCE:
<!-- pagebreak -->and itsimg.mce-pagebreakediting image;Word:
<br clear=all style="page-break-before: always">, includingmso-special-character: line-break;any block with
page-break-before/after: alwaysorbreak-before/after: page, such as Word headings and Google Docs' hidden<hr>. A break inside a paragraph splits it.
Comments and custom separators still print as page breaks: the plugin swaps them for the div form in the print.document filter. Markdown export writes the div form as HTML.
Full page
TinyMCE's Full Page HTML equivalent. The editor edits the body; the rest of the page is kept in document metadata:
| HTML | Metadata |
|---|---|
<title> |
metadata.title |
<html lang>, <html dir> |
metadata.language, metadata.direction |
<meta name="description">, keywords, author, viewport |
metadata.head.description, and so on |
Other <meta>, approved <link> relations, <style> |
metadata.head.meta, links, styles |
<body> and <html> attributes |
metadata.head.bodyAttributes, htmlAttributes |
Nothing from the head runs. Scripts are removed (or kept as inert data under scripts: 'preserve', and only written back with includeScripts). Event handlers, <base>, <meta http-equiv="refresh">, @import, expression() and javascript: URLs are removed and reported. Output is always UTF-8.
import { parseHtmlDocument, serializeHtmlDocument } from '@better-rich-text/format-html';
const { body, metadata, diagnostics } = parseHtmlDocument(html);
const page = serializeHtmlDocument(bodyHtml, metadata);The Document properties dialog edits title, description, keywords, language and direction through document.setProperties.
exportEmail(host, root, options) (or the document.exportEmail command) produces HTML that mail clients render consistently:
Every rule of the content stylesheet is inlined into
styleattributes by the normal cascade. Rules that cannot be inlined, such as media queries, stay in a<style>block.var()references resolve fromoptions.variables, so a skin's tokens can be passed in. Logical properties become physical ones for the text direction.With
layout: 'table'(the default) content sits in a centred table ofwidthpixels (600 by default).Relative links and images become absolute through
baseUrl. Localblob:images are removed;data:images are kept and reported.Video, audio and frames become links. Forms, scripts and embedded objects are removed. Checklists become ☐ and ☑ lines. Classes and
data-*attributes are dropped.textis a plain-text alternative: underlined headings, list markers,>quotes, link addresses in brackets, pipe-separated table rows, wrapped at 76 columns.Merge fields such as
{{first_name}}pass through unchanged.
Pass css to replace the built-in content stylesheet (EMAIL_CONTENT_CSS) or extraCss to add brand rules. inlineCss(html, css) is available on its own.
Hooks
| Hook | Kind | Arguments |
|---|---|---|
docx.exportNode |
filter | (xml: string | null, node, { kind }); return OOXML to replace a node's output |
docx.exportParts |
filter | (parts: Map<path, bytes | string>); add or replace package parts |
docx.importNode |
filter | (html, element, context); rewrite the HTML produced for a Word block |
email.inlineStyles |
filter | (declarations, { tag, attributes }); adjust each element's inline styles |
email.output |
filter | ({ html, text, issues }, root) |
print.document |
filter | (html, settings) |
documents.exported |
action | { format, filename, issues, revision } |
documents.imported |
action | { format, mode, issues } |
documents.failed |
action | { format, error } |
print.ready |
action | (frameDocument, settings) |
Limitations
Word export writes one section with one default header and footer of plain text. Section breaks, first-page and even-page headers, and formatting or images in headers and footers are not written; on import they are left out and reported, and their text is kept.
Comments carry message text, authors, dates, replies and resolved state. Reactions, edit times, who resolved a thread and formatting inside Word comments do not survive. Threads not attached to text are not exported. Comment threads on content inserted at the cursor (rather than replacing the document) become orphaned, because inserted content gets new node ids.
Suggestion and author ids do not survive Word. Authors come back by name (see
resolveAuthor), and changes by one author at one time next to each other become one suggestion.Suggested wraps, unwraps and moves (lists and quotes) have no Word tracked-change form: they are written as proposed, and
trackedChanges: 'reject'leaves them in place with a warning. Changes to images, table cells, numbering and paragraph properties other than the style are imported in their current form. Tracked paragraph marks in lists and tables become proposed breaks only between neighbouring items.Spaces at the start or end of a paragraph are dropped on import.
Some attributes do not survive a Word round trip: code block language, reversed and Greek-numbered lists, links on images, image floats (exported inline), table banding, accent and density, and paragraph spacing before and after. Cell text alignment comes back as paragraph alignment.
Imported footnotes become superscript numbers and a notes list, because the schema has no inline note container.
Endnotes, equations (kept as text), shapes, text boxes, linked external images and ZIP64 or encrypted files are reported and skipped.
Images that cannot be fetched (cross-origin without CORS, for example) are exported as their alt text and reported.
Import keeps direct formatting that the schema supports (colour, font, size, highlight) and drops the rest, such as character spacing and text effects.
Legacy
.doc,.rtf,.odtand PDF import are not handled in the browser; they need a conversion service.Printed PDFs depend on the browser's print engine. Files from Download PDF are the same in every browser.
PDFs written in the browser do not shape text: Arabic letters are not joined, Indic scripts such as Devanagari lose their conjuncts, and right-to-left text is aligned right but not reordered. Use a server renderer for those scripts.
They have no kerning, ligatures or hyphenation, are not tagged for screen readers and are not PDF/A.
Floated images sit on their own line instead of having text wrap around them. Formulas are written as their TeX source, and footnotes are listed at the end of the document rather than at the foot of each page.
Stylesheets from the full page, the
cssprint setting and theprint.documentfilter apply to printing and server renderers only.Rows joined by a row span always move to a new page together, and a table nested in a cell does not split across pages.
Email output is checked against common client rules, but no export renders identically in every client.