v7.8.0
I am pleased to announce the release of officeParser v7.8.0! Embeds were the one construct in the Markdown dialect with no borrowed convention and the worst degrade: a YouTube video could only be written as an invented <div data-youtube-video> block that renders as an invisible empty box on GitHub, and a raw <iframe> was escaped into a wall of literal text. This release gives embeds a real, selectable Markdown form, adds a safe path for capturing untrusted iframes, and fixes an HTML round-trip whitespace bug.
Everything here follows the same rule as recent releases: no regression on any consumer. Every change is additive, a genuine bug fix, or non-standard becoming standard; where a default would move, the old behavior stays and is deprecated. The default embed output is byte-identical to 7.7.0.
Choose how an embed node is written:
'html'(default): the<div data-youtube-video>/<iframe>single-line block this library has always emitted and re-reads.'directive': a remark-directive leaf,::youtube[Label]{id=… width=… align=…}/::embed[Label]{src=… …}, both parsed and generated. An editor round-trip form (GitHub renders it verbatim rather than as a player, so it is not a GitHub-interop format).'link': a plain[YouTube](url)/[Embed](url).'thumbnail': a YouTube-only clickable preview[](watch), the best GitHub degrade.
::youtube parses unconditionally (rendered from a validated id via a fixed template). ::embed carries an arbitrary src, so it is gated behind preserveIframes (the trust input) and stays literal text otherwise. Unknown ::names stay literal, with no catch-all.
Off by default. When on, a generic (non-YouTube) iframe embed is emitted as an inert <div data-embed-gated data-embed-src> placeholder that never auto-loads its src. An editor renders a click-to-load control from it, and HtmlParser reads it back to the same embed node. The src is scheme-checked on emit. The default output (a live <iframe>) is unchanged. Combined with the existing preserveIframes gate, untrusted input is never escaped-as-text and never auto-rendered.
Off by default. When on, a standalone Obsidian image whose URL is a YouTube link () and a clickable thumbnail-link ([](watch)) import as safe YouTube embeds. Off by default because auto-upgrading an image or link is a heuristic that could mangle a genuinely-intended image link. The unambiguous forms are always recognized regardless of this flag.
The human label of a ::youtube[Label] / ::embed[Label] directive (and a gated embed's caption). It round-trips through the directive form, the generic gated data-embed-label, and the YouTube editor-HTML shape.
fallbackToHtml.embeds (boolean). Use mdConfig.dialect.embeds instead, which also selects the 'directive' and 'thumbnail' forms. While dialect.embeds is unset the boolean is still honored (true maps to 'html', false to 'link'). It will be removed in the next major.
The Markdown parser read a YouTube <iframe> as a generic 'iframe' embed with no videoId, and only under preserveIframes, while the HTML parser read it as 'youtube' unconditionally. Both parsers now detect a YouTube src the same way, before the preserveIframes gate, so the same input yields the same 'youtube' embed. The youtube-via-iframe HTML path now also carries the iframe's width/height.
HtmlGenerator appended a readability blank line after every node, including inline text and link runs, so See this [video](url). emitted a paragraph with \n\n around the <a>, which reparsed as a stray space before the punctuation. The blank line is now added only after block-level nodes; inline runs concatenate directly. This is a whitespace-only change to generated HTML (semantically identical), and it makes the md → HTML → md round trip correct.
npm install officeparser@7.8.0
🔗 Full Changelog: View v7.8.0 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.7.0
I am pleased to announce the release of officeParser v7.7.0! This release hardens the Markdown ↔ HTML round trip for the kind of rich content a ProseMirror/Tiptap-style editor produces. Nested lists, aligned tables, highlights, and link/image titles now survive a full md → HTML → md cycle. It also reworks the Markdown dialect configuration so every capability is named by the syntax it selects instead of a product flavor or a bare boolean.
The fidelity items are corrections: the only output that moves is content that was previously flattened, dropped, or emitted as invalid markup. The dialect rework is backward-compatible: existing boolean/flavor configs still work (they coerce to the new values), and are now deprecated.
Each mdConfig.dialect capability now names the syntax it selects: admonitions: 'blockquote' | 'fence' | 'fence-attribute', and strikethrough/definitionLists/footnotes/citations/wikilinks/attributeLists as '<marker>' | 'none' (e.g. strikethrough: 'tilde', wikilinks: 'double-bracket'). A shared convention is a single value, and a second syntax can be added later without a breaking change. A new highlight: 'equals' | 'none' capability joins them.
In dialects that define it (Obsidian/extended), a highlighted run emits as ==text== and ==text== parses back to a highlight. Other dialects keep the HTML <mark> fallback.
[text](url "Title") and  now keep their title in both directions. Previously an inline destination swallowed url "Title" as one URL.
A multi-paragraph list item joins onto its single Markdown line with <br> (default on), mirroring cellLineBreaks for table cells.
An HTML <li> that wraps its text in <p> (the shape rich-text editors emit) exported as - a\n\n\n - a1, which reparsed flat. List items are now tight, a conservative parser pass rejoins a blank-line-split child, and generated HTML nests spec-validly (<li>a<ul>…</ul></li> instead of the invalid sibling shape), which also makes generated EPUB XHTML valid.
:--- / :---: / ---: alignment now lives on each cell and is emitted as text-align on <th>/<td> (and read back), so per-column alignment survives md → HTML → md, the editor import path, instead of vanishing on the HTML hop.
Inline code emits <code> (not a font-family: monospace <span>); a single-line code block with a language stays a <pre><code> block; a plain <blockquote> round-trips to > quoted; an HTML <br> reads back as a hard line break rather than collapsing to a space; and an own-line $$…$$ parses as block math instead of leaking stray $.
Boolean dialect toggles (strikethrough: true) and admonition flavor names (admonitions: 'github') still work. They coerce to the new syntax values (true becomes the marker, false becomes 'none'; flavors become 'blockquote'/'fence'/'fence-attribute'), but are deprecated and will be removed in the next major. Prefer the syntax names.
- Fixed a cold-run
npm testflake where a PDF-OCR parity timeout was killed at the 30s cap and misreported as a0.0% similaritycontent mismatch. Timeouts now surface distinctly and the OCR run gets a 120s budget. Test tooling only. (#111)
npm install officeparser@7.7.0
🔗 Full Changelog: View v7.7.0 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.6.2
I am pleased to announce the release of officeParser v7.6.2! This patch closes four Markdown/HTML round-trip fidelity bugs found while driving a real editor's save → load → save cycle through officeParser: a single-line code block losing its language, inline code losing its backticks, a raw <br> being escaped, and a table header emitting invalid HTML. Each was a case where officeParser's own output did not reparse into what produced it; all four now round-trip cleanly.
These are corrections, not new behavior to guard against: the only output that moves is content that was previously lost, escaped, or emitted as invalid markup, so upgrading fixes those cases rather than disturbing working ones. If you built a workaround for any of them, you can drop it.
MarkdownGenerator chose fenced-vs-inline purely by whether the code text contained a newline, ignoring the language, so a one-line code block (const x = 1; tagged js) or a one-line mermaid diagram collapsed to an inline `code` span. That silently dropped both the language and its block-ness, and re-imported as an inline code mark rather than a code block. A code node is always block-level (genuinely inline code is a monospace text node), so a code node with a language now always emits as a fenced block regardless of newlines. A tagged `const x = 1;` becomes a proper js block again.
Inline code parses to a monospace text node, but the generator's text-node path had no backtick emission for it, so every inline `code` (and inline <code> from HTML) degraded to plain text on md → md and html → md. Monospace text is now re-wrapped in backticks, fence-sized so an embedded backtick can't close the span early, with emphasis wrapping the span so **`code`** round-trips.
MarkdownGenerator emits a raw <br> for a line break inside a table cell (a GFM pipe cell cannot hold a newline), but MarkdownParser did not read it back. It escaped <br> to <br>, destroying the break on the md → html hop, in cells and paragraphs alike. The parser now recognises <br> / <br/> / <br /> as a hard line break, symmetric with what the generator writes, so a <br> survives the round trip. A table-cell line break, ubiquitous in Word-imported forms and exams, is now preserved.
generate('html') emitted a table's header cells directly under <thead> (<thead><th>…) with no wrapping <tr>, which is invalid HTML that officeParser's own HtmlParser could not read back as a table, so a md → HTML → md round trip lost the header (its cells came back empty). The header row is now wrapped in a <tr> (<thead><tr><th>…), which is valid and self-idempotent.
- Heading anchors are already toggleable.
generate('md')appends a kramdown/Pandoc{#slug}suffix to headings (# Title {#title}), which GFM/CommonMark render as literal text. The existing top-levelgenerateIds: falseomits it (and the HTML headingids). It is a generator-wide option, not undermdConfig, which made it easy to miss. Now documented in the README.
npm install officeparser@7.6.2
🔗 Full Changelog: View v7.6.2 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.6.1
I am pleased to announce the release of officeParser v7.6.1! This release teaches HtmlParser the on-the-wire shapes that structured (Tiptap-style) editors actually emit, and makes a document survive the full save → load → save cycle without quietly losing footnotes, highlights, horizontal rules, or frontmatter types along the way. The guiding rule throughout: expand, never inhibit — every parser change only widens what is accepted, and every new generator behaviour is off by default, so existing output is byte-identical except for the deliberate fixes called out below.
(This release also carries the changes staged as 7.6.0, which was merged to master but never separately published — everything is delivered together here.)
Thanks to @pipaacebedo (#109) and @MohammedAlkindi (#111), whose reports drove the fixes below.
[!WARNING] Behavior changes
- A DOCX paragraph mark's run properties no longer bleed onto every run. Bold/italic/underline/colour/size/font set on a paragraph mark (
<w:pPr><w:rPr>) were folded into the base formatting of all the paragraph's runs. Per OOXML ISO 29500 §17.3.1.29 those properties format only the mark glyph; runs now inherit only from the style chain and their own run properties, so formatting on affected runs differs from prior releases. (#109)- Thematic breaks (
---/<hr>) now survive a save. A Markdown---and an HTML<hr>parsed to a page break, which the Markdown generator emits as a bare newline — so a horizontal rule silently vanished on the first save. It is now a distinctbreakType: 'thematic'that emits---in Markdown and<hr>in HTML; an office page break (<hr class="page-break">) stays a page break.- Highlights are generated as
<mark>, not<span style="background-color">. Editors whose highlight extension matches only themarkelement (e.g. Tiptap's Highlight) now rehydrate a highlighted run that previously came back as plain text.<mark>anddata-colorare also parsed on import.- Footnotes no longer grow a
### Notesheading on every save, and empty metadata no longer corrupts the cycle. The Markdown footnote section is emitted as bare[^id]:definitions (byte-stable across cycles), and a document with no metadata fields no longer emits an---\n---block that reparsed as a heading.- Frontmatter scalar types are preserved. A quoted
version: "123"stays a string across a round trip instead of coercing to a number; only bare scalars coerce (YAML semantics).EmbedMetadatawidened.embedTypeis now'youtube' | 'iframe',videoIdis optional, and aheightfield is added. Strict TypeScript consumers that readvideoIdas a non-optionalstring, or switched exhaustively onembedType, may need a small type adjustment.- The footnote-definition HTML is a
<div data-footnote-id>, not a<p>wrapping block content (which every DOM parser split). Default footnote-definition markup changes.
HtmlParser now accepts the shapes structured editors serialize, so content authored in an editor round-trips through officeParser instead of flattening to plain text on the way back:
a[data-wikilink]— page indata-target, display text from the anchor body ordata-alias.span.citation[data-key]— the same bare-key citation node as<cite data-citation-key>.data-math— disambiguated by value: the library's owndata-math="inline|block"is read exactly as before, while any other value is taken as the raw LaTeX (previously read as inline math with the attribute ignored).div[data-mermaid]/div.mermaid/pre.mermaid— mapped to amermaidcode node (previously the div flattened to paragraph text).
The complementary emission is opt-in behind one behavior-named key, HtmlGeneratorConfig.sourceAttributes (default false), so an attribute-driven consumer can rehydrate each node from a data-* attribute. Off by default, output is byte-identical; on, the widened parser reads back every shape it emits, so output stays self-round-trippable. Every sink is entity-escaped, and PDF/EPUB generation force the flag off.
// Round-trips cleanly with the editor's own serialization:
const html = String((await ast.to('html', { htmlConfig: { sourceAttributes: true } })).value);
// <a data-wikilink="true" data-target="Page" data-alias="Alias">…</a>
// <span class="citation" data-key="smith2020">…</span>
// <div class="mermaid" data-mermaid="graph TD; A-->B">…</div>
Footnotes were the single biggest source of quiet corruption on the editor's save/load path, and this release closes every case that surfaced:
- Multi-line definitions continue across indented lines (Pandoc/GFM) and re-emit indented, instead of being cut short at the first newline.
- Orphan definitions — a
[^x]: …with no matching reference — are preserved on both sides (recovered from.mdand from asection[data-footnotes]) with no dangling back-link, so they survive a full md → HTML → md trip instead of being dropped. - Repeated references to one id stay
[^1]/[^1]with a single definition, rather than renumbering to[^1]/[^2]and duplicating the body. Office notes that merely share a numeric id (a footnote and an endnote both numbered1) remain distinct. - A footnote referenced in a table cell is defined exactly once; the generator no longer double-processes cells and pushes the note twice.
- Footnote and endnote bodies now reach RAG chunks (office and Markdown origin), folded into the referencing node's chunk text — searchable where they were previously absent.
parseOffice and OfficeConverter.convert accept a web Blob/File (or any BlobLike with an arrayBuffer() method), so browser callers no longer convert to a Buffer first. A filename drives extension-based detection; a nameless blob resolves through magic-byte sniffing.
const file = document.querySelector('input[type=file]').files[0];
const ast = await parseOffice(file); // Blob/File accepted directly
MdGeneratorConfig.fallbackToHtml.inlineFormatting(defaultfalse, opt-in even whenfallbackToHtmlistrue) round-trips inline colour, highlight and font size through.mdas a sanitized<span style>run — formatting that has no Markdown syntax and was otherwise lost when.mdis the storage format.HtmlParserConfig.preserveIframes(defaultfalse) keeps non-YouTube<iframe>embeds that are otherwise dropped.truepreserves any iframe; an array is a hostname allowlist. The src is scheme-checked on generation, so ajavascript:/data:src never survives.
- Chunking dropped HTML- and Markdown-origin paragraph text.
ChunkingGeneratorread a node's own.textwith no fallback to its children, so paragraphs built as{ children: [...] }chunked to empty. It now collects text recursively, restoring near-parity with.to('text'). <pre><code>blocks did not decode HTML entities. Escaped characters (<,>,&— e.g. a mermaid-->arrow) surfaced still-escaped in text/Markdown/chunk output and double-escaped a little more each HTML round trip. They are now decoded to their literal characters.npm testnow runs end-to-end on Windows. The build/test scripts use Node'sfsinstead ofmkdir -p/cp/rm -rf, the ESM test helper loads the bundle via afile://URL, and the CLI test launches the CLI throughnoderather than thenpxshim (which a directspawnSynccannot start on Windows). Test-tooling only — the published library is unchanged. (#111)- The public config and
*Metadatatypes are exported from the package root, soimport type { HtmlGeneratorConfig } from 'officeparser'resolves (previously only the browser.d.tscarried them). rtfConfigwas the one generator sub-config not deep-merged in config resolution; the merge is corrected so future fields behave like every other sub-config.- Documentation:
generate(ast, 'chunks')returns anOfficeChunk[]array, not a JSON string; consumers serialize to JSON/JSONL themselves.
npm install officeparser@7.6.1
🔗 Full Changelog: View v7.6.1 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.5.1
I am pleased to announce the release of officeParser v7.5.1! This patch release closes three reported issues around one theme: trusting the file in front of us less, and telling you more. Unreadable input now fails with typed errors instead of parsing as an empty document, type detection reads the archive's own declaration when magic-byte sniffing gives up, and the browser bundles no longer break webpack builds.
Thanks to @benhid, @SteveNewhouse and @ayazaurie, whose reports drove this release.
[!WARNING] Behavior changes
- Corrupt input throws; it no longer parses as an empty document. Data that is not a ZIP archive, an archive cut off in transfer (or carrying data after its end-of-archive record), and a readable ZIP missing the part its format requires (
word/document.xml,xl/workbook.xml,ppt/presentation.xml, ODFcontent.xml, the EPUB OPF) now reject withZIP_NO_ENTRIES_FOUND,ZIP_TRUNCATEDandREQUIRED_PART_MISSINGrespectively. An empty result therefore means the document really is empty. If you relied on unreadable files resolving quietly, catch and branch onerror.officeIssue.code.- DOCX headers, footers and comments now appear in output. They were documented but never extracted, so
ast.auxiliary.headers/.footerswere always empty and comments never reached the AST. Documents that have them will now produce them;ignoreHeadersAndFootersandignoreCommentsrestore the old shape if you want it.- Config objects are no longer shared with the library. Reusing one config across calls previously leaked each parse's warnings into earlier, already-returned
ast.warningsarrays, and generation could silently rewritehtmlConfig.containerWidthon your own object. Resolution now copies; callbacks andabortSignalkeep their identity.
Since the 7.3.0 streaming ZIP rewrite, a corrupt buffer resolved into an empty AST with no warnings, indistinguishable from a genuinely empty document. Three distinct failure modes are now typed rejections, and every thrown error carries its structured issue so you branch on a stable code instead of matching message text:
try {
const ast = await parseOffice(buffer, { fileType: 'docx' });
} catch (err) {
switch (err.officeIssue?.code) {
case 'ZIP_NO_ENTRIES_FOUND': // not a ZIP archive at all
case 'ZIP_TRUNCATED': // cut off in transfer, entries incomplete
case 'REQUIRED_PART_MISSING': // readable ZIP, but not the format it claims
console.error('Unusable file:', err.officeIssue.message);
break;
default:
throw err;
}
}
The OfficeError type is exported for TypeScript users. Files that are legitimately empty still parse and now say so: a chartsheet-only workbook emits NO_WORKSHEETS_FOUND and a zero-slide deck emits NO_SLIDES_FOUND through onWarning / ast.warnings, so an empty result is never silent in either direction. Two ODF gaps closed alongside: a valid .ods/.odp without a mimetype entry was walked as a text document and came back empty (it now falls back to your fileType or the extension), and a missing root content.xml can no longer silently promote an embedded Object N/content.xml chart to the document body.
Passing a bare Buffer relied entirely on magic-byte sniffing, which walks a ZIP under fixed budgets: at most 1024 entries, and roughly 1 MiB of scanning when entry sizes are deferred to trailing data descriptors (general-purpose flag bit 3, what streaming ZIP writers produce). A valid PPTX whose [Content_Types].xml sat beyond either budget was reported as generic zip, so parseOffice threw "Sorry, OfficeParser currently supports docx, pptx, xlsx... add support for zip files" for a perfectly good document. Both layouts occur in the wild, and every ZIP-backed format was affected, in Node and in the browser.
When sniffing is inconclusive, the archive is now opened with officeParser's own reader, which has neither budget, and the format is taken from [Content_Types].xml or the ODF/EPUB mimetype entry. Detection inflates at most 4 MiB regardless of your decompression limits, a correct fileType hint skips the archive scan entirely and remains the fastest path, and a bare zip sniff is no longer misreported as a BUFFER_TYPE_MISMATCH against your own hint.
One dynamic import inside the bundles escaped the build's webpackIgnore annotations, because the annotator treated an interpolated template literal as a static string. Webpack does not skip a specifier it cannot resolve: it builds a context module over the whole directory, so consumers ended up bundling all of dist/, Node-only files included, and their builds failed on child_process. The annotator was rewritten around a shared, tested classifier, child_process and url now resolve to browser stubs, and a webpack 5 build of the ESM and slim ESM bundles is warning-free. Shipping checks now scan every bundle so this class of regression cannot ship again.
- DOCX headers, footers and comments were never extracted. The parse code existed but the parts were missing from the archive extraction filter, so it could never run. Part names now also match past
header9.xml, which documents with several sections reach. - PPTX document-property parts were parsed as slide candidates.
docProps/app.xmlandcustom.xmlare now skipped in the slide loop on identity, closing the same phantom-slide trap thatppt/presentation.xmlneeded. - Typed errors were reported twice, with a doubled
[OfficeParser]:prefix and their code flattened toFILE_CORRUPTEDby the wrapping layer. Errors now pass through once, intact. - Archive extraction errors ignored
outputErrorToConsoleandonWarning, always writing to the console. They now report through your handlers like every other issue. - Memory: the Word, Excel and PowerPoint parsers each kept the full XML source of every extracted part in a write-only buffer under
includeRawContent. Dropped; serialized ASTs are byte-identical.
npm install officeparser@7.5.1
🔗 Full Changelog: View v7.5.1 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.5.0
officeParser v7.5.0 makes equations a first-class part of the AST across every format that can carry them, and closes two ODF gaps: paragraph styling that never reached the text, and page breaks that were never emitted.
Thanks to @njoqi and @benhid, whose reports and sample documents drove this release.
[!WARNING] Behavior changes
- Equations are now
codenodes, not text. ODF equations previously came through as plaintextnodes holding an ad hoc notation ((1)/(2)). They are nowcodenodes carryingCodeMetadata.math, with LaTeX innode.text(\frac{1}{2}) — matching what Markdown already produced.ast.toText()output changes accordingly.- Redundant heading emphasis is no longer emitted. A heading whose every run is bold used to render as
# **Heading**in Markdown, and in RTF/HTML as an inner font size that overrode the heading's own. Uniform bold/size inside a heading or table header row is now dropped in favour of the element's own styling. Partial emphasis (# Normal **Bold** Normal) is untouched.metadata.styleMapflags are tri-state. A style that explicitly turns formatting off (ODF'sfo:font-weight="normal") now appears asfalserather than being absent. OnstyleMap, absent means the style is silent and the property inherits;falsemeans it is explicitly off. Code resolving inheritance itself must test=== undefined, not truthiness. Content nodes never carryfalse.
Equations were not merely missing before this release — they were silently corrupted. Office documents ship them in two markups: OOXML's <m:oMath> (DOCX, PPTX) and MathML <math> (ODF embedded objects, HTML, EPUB3). Neither was handled, so both fell through to a generic "concatenate the descendant text" fallback:
| Before | Now | |
|---|---|---|
½ + ⅛ + ¹⁄₃₂ |
12+ 18+132 |
\frac12+ \frac18+\frac1{32} |
3/42 |
342 |
\frac3{42} |
(2³×2⁷)² |
(23×27)2 |
{(2^3×2^7)}^2 |
f(x) = ⅓x³ + … |
fx= 13x3+… |
f(x)= \frac13x^3+… |
ℝ |
R |
\mathbb{R} |
1/2 → 12 and 3/42 → 342 produce plausible-looking numbers, so nothing downstream could tell the value was wrong. In one reporter's maths exam paper, 37 of 121 equations were affected. PowerPoint was worse in a quieter way: its run loop dispatches on <a:r>, and an <m:oMath> is a sibling of the runs rather than one of them, so PPTX equations were dropped entirely.
Every format now converges on one node:
Code Node (type: 'code')
├── text: '\frac{1}{2}' // LaTeX, whatever the source markup was
└── metadata: { math: 'inline' | 'block' }
Fractions, sub/superscripts, radicals, delimiters, n-ary operators, named functions, accents, bars, matrices and math alphabets are all preserved. A document's own <annotation encoding="application/x-tex"> is used verbatim in preference to anything reconstructed from the presentation markup. Because everything is LaTeX, a formula now survives a docx → md → docx round trip instead of degrading at each hop.
It was implemented only in WordParser, and the docs said "DOCX only". The reason was that ODF has no inline break element to find — page and column breaks live on the paragraph style as fo:break-before / fo:break-after. Those now emit break nodes, and <text:soft-page-break/> maps onto the same lastRenderedPage type DOCX uses.
[!NOTE] DOCX writes breaks inline, so they land as children of the paragraph. ODF scopes them to the paragraph style, so they are emitted as siblings around it.
A paragraph style carrying fo:font-weight, fo:font-size or fo:color was parsed into the style table and referenced from the node's metadata — but the runs inside were built with empty formatting, so every generator emitted the text unstyled. The formatting sat visible in the AST and was simply never applied.
Runs now inherit the paragraph's formatting. That made a second gap load-bearing: styles that explicitly turn formatting off were not recorded at all. LibreOffice writes fo:font-weight="normal" whenever you un-bold part of a bold-styled paragraph, and with nothing recorded, that span had no way to override what it now inherited. Off-states are recorded, and a span carrying one clears the inherited value.
- Every table subtree was rendered twice (#105). The Markdown generator walked a node's children to build its output, then the table processor discarded that and traversed the rows and cells again. The two traversals compounded with nesting depth, so conversion time grew quadratically — a 184 KB document of nested tables took 7.5s. Footnotes inside table cells were collected on both passes and appeared twice. Time now grows linearly with input size.
- Text inside PowerPoint grouped shapes was silently dropped (#106).
traverseSpTreelooked for a nested<p:spTree>inside each<p:grpSp>. A group does not contain one — per the schema it has the same content model asspTreeitself — so the lookup always failed and the group's contents were skipped. Nested groups are covered too. - Security: an ODF table row carrying a large
table:number-rows-repeatedwith no cells bypassed the per-document cell budget entirely, so a 733-byte file could exhaust memory and crash Node. Zero-cell rows are now charged against the same budget as every other repeat.
npm install officeparser@7.5.0
🔗 Full Changelog: View v7.5.0 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.4.0
I am thrilled to announce the release of officeParser v7.4.0! This release brings major enhancements to our Markdown generation capabilities with targeted dialects, introduces metadata overrides for document authors, enables granular HTML attribute pass-through, and applies another critical layer of validation and security hardening across spreadsheet parsing and recursive nesting.
[!WARNING] Behavior changes in this release:
- RTF hyperlink scheme restriction: RTF hyperlinks are now restricted to the same safe schemes allowed in HTML and Markdown. Local intranet
file://or UNC path links will lose their click targets (the link text is kept).styleMapdefault mappings in HTML:styleMap'soutput.tagnow takes effect in HTML output. This activates built-in default style mappings for HTML (e.g. mappingHeading N/Quote/Title-styled paragraphs to their respective semantic tags).- Clamped repeated spreadsheet cells: ODF spreadsheets with
table:number-columns-repeatedortable:number-rows-repeatedare now capped atdecompressionLimits.maxTableCells(default: 1,000,000) to prevent memory exhaustion from zip bomb constructs.- Lowered HTML nesting-depth guard: Lowered the HTML parser's nesting-depth guard to 256 (from 1000) so a deeply nested document raises the typed error instead of a range error.
I've significantly upgraded the Markdown generator to support real-world flavors:
- Targeted Dialects (
MdGeneratorConfig.dialect): Generate output optimized for specific platforms:'github','gitlab','obsidian','pandoc', strict'commonmark', or the default'extended'. You can also pass a detailedMarkdownDialectConfigobject to toggle admonitions, footnotes, citations, wikilinks, math, tables, list markers, and emphasis on a per-feature basis. - Granular HTML Fallbacks (
MdGeneratorConfig.fallbackToHtml): The fallback flag can now accept aFallbackToHtmlConfigobject to independently control how text formatting, alignment, anchors, tables, embeds, and cell line breaks degrade to HTML when not natively representable. The plain boolean form remains supported.
- Metadata Overrides (
GeneratorConfig.metadataOverrides): Author metadata can now be specified at the generation stage (including title, author, subject, keywords, created, modified, language, and custom properties) without mutating the parsed AST. This applies across HTML, EPUB, Markdown, RTF, and plain text/CSV headers. - EPUB Reproducibility: Specifying the
modifiedoverride metadata yields byte-stable EPUB output by fixing thedcterms:modifiedproperty and all zip entry modification times (which previously defaulted to the current runtime). - Keywords and Subject: These properties are now written out to HTML
<meta>, Markdown frontmatter, EPUBdc:subject, and RTF\info.
- Preserve Attributes (
htmlParserConfig.preserveAttributes, defaultfalse): You can now opt-in to preserve generic HTML attributes on round-trips. They will map toBaseContentNode.htmlAttributes. High-risk attributes like event handlers (on*),srcdoc, inlinestyle, andidare filtered out.
- Parsers have been expanded to handle reference-style links/images, backslash escapes, underscore emphasis, multi-backtick inline code, setext headings,
<url>autolinks, HTML entity and character references, and~~~-fenced blocks. - Footnotes now degrade gracefully when disabled or under strict CommonMark, rendering as parentheticals rather than disappearing.
- Spreadsheet Cell Cap: Clamps pathologically large repetition counts in ODF spreadsheets, preventing memory exhaustion.
- Recursive Nesting Guard: The HTML parser's nesting guard has been lowered to
256(from1000) to safely throw a typed error before Node.js stacks overflow. - Config pollution protection: Standardized deep config merges now explicitly guard against
__proto__pollution during JSON parsing. - CSS sanitization bypass fixed:
sanitizeCssValuenow strips backslash escapes before checking for prohibited patterns likeurl(). - RTF Hyperlink schema restrictions: hyperlinked paths are restricted to safe web schemes.
- Contextual escaping: Fixed missing escapes in Markdown generation for math blocks, wikilinks, citation keys, admonition types, and footnote IDs.
- Fixed: Plain-text
.to('text')and.to('md')output no longer trims document-level whitespace, resolving issue #102 by only stripping the generator's internal separator artifacts. - Fixed:
.to('text')now preserves chart data series, CSV comments, and separates adjacent table cells cleanly instead of merging them. - Fixed: CSV formula safety prefixing is now tested against trimmed cell values.
- Fixed: Inline stylesheet parsing is now powered by a real CSS declaration parser rather than substring matching.
npm install officeparser@7.4.0
🔗 Full Changelog: View v7.4.0 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.3.0
I am thrilled to announce the release of officeParser v7.3.0! This release adds EPUB as a first-class parsed and generated format, brings the Markdown/HTML pipeline up to parity with the rest of the ecosystem (task lists, admonitions, footnotes, citations, wikilinks, and more), and closes out a full security-hardening pass across the engine.
[!WARNING] Behavior change:
standalone: falseHtmlGeneratorConfig.standalone(plain booleanfalse) now emits a genuinely bare HTML fragment — no<style>, no<script>— instead of the old global, unscoped stylesheet that would leak onto whatever page you embedded it in. If you relied on that stylesheet, usestandalone: { document: false }instead, which reproduces the old output byte-for-byte.
epub is now a first-class format on both sides.
- Parsing:
EpubParserunzips the archive, resolves the spine's reading order, and parses each XHTML document through the existingHtmlParser— so EPUB content gets the same AST shape (and the same Markdown-dialect fidelity below) as every other format. Dublin Core metadata (title, author, description, language, etc.) maps straight intoast.metadata. - Generating:
EpubGeneratorproduces a valid, strict-XHTML EPUB 3 archive, with images packaged as real zip entries (notdata:URIs, which most e-readers won't render). ⚠️ PassextractAttachments: truewhen converting to/from EPUB if the document has images —OfficeConverter.convert()already does this for you.
The following now round-trip through both the Markdown and HTML pipelines:
- GFM task lists —
- [x] Done/- [ ] Todo - Admonitions/alerts — GitHub's
> [!NOTE]and GitLab's:::note ... ::: - Real footnotes —
[^id]references and definitions (previously faked as blockquotes) - Definition lists & abbreviations — Markdown Extra style
- Pandoc attribute lists —
{width=50% .centered}on images and tables - Citations —
[@citekey] - Wikilinks — Obsidian-style
[[Page]]/[[Page|Alias]] - Math — inline
$...$and block$$...$$LaTeX now tokenise instead of passing through as plain text - MDX import stripping — JSX tags are stripped on import (parse-only; never authored back out)
- Frontmatter arrays —
tags: [a, b]now parses to a real array, not a literal string
Plus long-standing HTML round-trip gaps closed: image size/alignment, table alignment, merged cells (colspan/rowspan), and YouTube embeds all now survive a save → reload cycle instead of being write-only.
HtmlGeneratorConfig.standalone now accepts an object, not just a boolean:
htmlConfig: {
standalone: {
document: false, // drop the <html>/<head>/<body> shell
styles: 'scoped', // new: CSS wrapped in @scope so it can't leak onto a host page
// metaTags, scripts, headInjections, bodyInjections all independently toggleable
}
}
Every field defaults to its "on" (fully-standalone) value when omitted, so { document: false } alone gives you a fully-styled fragment with just the outer shell removed. See the breaking-change note above for what moved.
A pass across the whole engine treating every parsed document as untrusted input:
- Centralized output sanitization (
src/utils/sanitize.ts) — every generator now escapes document-derived text per destination context: HTML/XML attributes, inline CSS (blocksurl()/expression()/javascript:), URLs (rejects script-executing schemes), inline<script>payloads, CSV cells (formula/DDE injection), RTF control words, and Markdown text/URLs. - Zip bomb protection — decompression now caps against actual inflated bytes as they stream in, not the ZIP header's (attacker-controlled) declared size.
- DoS hardening — removed an O(n²) hot path in HTML parsing, capped recursion depth on malicious element nesting, and capped the MDX-unwrap loop's iteration count.
- SSRF hardening — PDF generation now blocks the rendering browser from fetching any remote resource that isn't an inline image or the configured chart CDN.
- PDF parsing hardening — disabled pdf.js's
eval-based fast path for untrusted PDFs.
All covered by a new dedicated security regression suite (npm run test:security).
- Fixed: Standalone bookmark-anchor blocks and table cells using the alignment-div fallback now survive a Markdown save → reload cycle instead of degrading into escaped literal text.
- Testing: A new exhaustive fixture suite (
npm run test:exhaustive) covering every AST construct per format, run as part of the standard test gate alongside the existing baseline/parity suites. - Docs: README and SECURITY.md now spell out officeParser's security posture plainly — I actively harden the library against malicious input (see above), but as with any software, no guarantee of being lapse-free is possible, and responsibility for a compromised input file's downstream impact ultimately rests with the consumer of the library. See the new Security & Trust Boundary section in the README.
npm install officeparser@7.3.0
🔗 Full Changelog: View v7.3.0 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.2.3
I am pleased to announce the release of officeParser v7.2.3! This patch release introduces dedicated slim browser bundles for strict runtime environments, support for formula parsing in ODF, and upgrades core dependency versions.
To support deployment inside environments with strict security controls (such as Chrome and Edge Manifest V3 extensions where remotely hosted code is prohibited), we now distribute slim ESM and IIFE browser builds:
dist/officeparser.browser.slim.mjs(ESM)dist/officeparser.browser.slim.iife.js(IIFE)dist/officeparser.browser.slim.d.ts(Types)
In the slim bundles, the tesseract.js (OCR) engine is completely stubbed out, and default remote CDN references are stripped, reducing bundle footprints and ensuring compliance with web store policies.
Added native extraction for MathML formulas in OpenOffice/LibreOffice formats (.odt, .odp, .ods) at both block level and inline cell level, preserving mathematical notation.
- Upgraded
pdfjs-distto6.1.200for optimized rendering performance, modern Node.js compatibility, and security mitigations. - Upgraded
fflateto0.8.3to resolve Zip64 archive parsing issues.
npm install officeparser@7.2.3
🔗 Full Changelog: View v7.2.3 details 🔗 Documentation & Visualizer: officeparser.harshankur.com
v7.2.0
I am thrilled to announce the release of officeParser v7.2.0! This major update brings a massive architectural upgrade to the AST, empowering developers with deeper insight into document layout, embedded metadata, and bulletproof TypeScript integrations.
As we pave the way for building advanced RAG architectures, deep-document search systems, and robust AI parsing pipelines on top of officeParser, v7.2.0 guarantees that every piece of document intelligence—from slide masters to hidden footnotes—is logically structured and heavily typed.
[!WARNING] Soft Breaking Change: Notes Placement
If your application iterates overast.contentto manually extract footnotes, endnotes, or slide speaker notes, you will need to update your logic. These nodes are no longer appended to the main content array. They are now structurally nested inside thenotes[]array of their logical parent or preceding text node.
Previously, footnotes, endnotes, and slide speaker notes were flattened and appended to the end of the document content. In v7.2.0, these notes are now strictly attached to their logical parent or preceding sibling nodes via a new node.notes[] array. Note: The legacy putNotesAtLast config flag is now deprecated.
The new ast.auxiliary property unlocks out-of-band document templates! officeParser now automatically extracts headers and footers from Word documents (ast.auxiliary.headers / footers), and Slide Masters from PowerPoint presentations (ast.auxiliary.slideMasters). These are neatly separated from the main sequential document flow.
The OfficeMetadata interface has been radically upgraded. Alongside canonical metadata fields (title, author, dates), officeParser now exposes format-specific verbatim metadata via ast.metadata.nativeProperties (e.g., <meta> tags in HTML, app.xml stats in DOCX, XMP dicts in PDF) and user-defined variables via ast.metadata.customProperties.
The generic OfficeContentNode interface has been completely refactored into a strict TypeScript Discriminated Union. This unlocks precise, compile-time type narrowing per node.type (e.g., safely accessing SlideMetadata only when type === 'slide'), eliminating the need for generic fallback assertions across your application.
The HTML Generator just got significantly smarter:
- Interactive Spreadsheets: Spreadsheets generated from Excel or CSV files now render with desktop-class interactivity, featuring native draggable boundary handles (
.col-resizer) to dynamically resize rows and columns in the browser. - Granular Layout Controls: Expanded
HtmlGeneratorConfigwithcontainerWidth,customCss, and DOMinjections(head/body hook insertions).
npm install officeparser@7.2.0
Example of using the new Discriminated Unions, Auxiliary nodes, and Structural Notes:
import { parseOffice } from 'officeparser';
const ast = await parseOffice('presentation.pptx', {
ignoreSlideMasters: false
});
// Access Slide Masters from the new auxiliary AST branch
const masterSlides = ast.auxiliary?.slideMasters || [];
console.log(`Found ${masterSlides.length} master slides!`);
// Confidently narrow types using Discriminated Unions!
for (const node of ast.content) {
if (node.type === 'slide') {
// TypeScript now explicitly knows this is a Slide node.
// Slide Notes are now structurally nested under the slide!
const noteCount = node.notes?.length || 0;
console.log(`Slide ${node.metadata.pageNumber} has ${noteCount} notes attached.`);
}
}
🔗 Full Changelog: View v7.2.0 Details 🔗 Documentation & Visualizer: officeparser.harshankur.com
Since 2019, officeParser has been maintained as a voluntary project, growing to support over 10 million downloads and 300,000+ weekly installations.
As I build the ultimate document-to-AI pipeline, I seek professional sustainability to fund officeParser's next milestones:
- Core Sustainability: Keeping up with dependency updates, test coverage, and performance tuning.
- Multi-Runtime Excellence: Official support for Bun, Deno, and Edge (Cloudflare Workers, Vercel).
- Enterprise Connectors: Dedicated integrations with LangChain, LlamaIndex, and Haystack.
If officeParser powers your production workflows or AI pipelines, please consider supporting its development:
👉 GitHub Sponsors 👉 Buy Me A Coffee